this repo has no description

Compare changes

Choose any two refs to compare.

+39
appview/models/pipeline.go
··· 1 package models 2 3 import ( 4 "slices" 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" ··· 50 } 51 52 return 0 53 } 54 55 func (p Pipeline) Counts() map[string]int {
··· 1 package models 2 3 import ( 4 + "fmt" 5 "slices" 6 + "strings" 7 "time" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 52 } 53 54 return 0 55 + } 56 + 57 + // produces short summary of successes: 58 + // - "0/4" when zero successes of 4 workflows 59 + // - "4/4" when all successes of 4 workflows 60 + // - "0/0" when no workflows run in this pipeline 61 + func (p Pipeline) ShortStatusSummary() string { 62 + counts := make(map[spindle.StatusKind]int) 63 + for _, w := range p.Statuses { 64 + counts[w.Latest().Status] += 1 65 + } 66 + 67 + total := len(p.Statuses) 68 + successes := counts[spindle.StatusKindSuccess] 69 + 70 + return fmt.Sprintf("%d/%d", successes, total) 71 + } 72 + 73 + // produces a string of the form "3/4 success, 2/4 failed, 1/4 pending" 74 + func (p Pipeline) LongStatusSummary() string { 75 + counts := make(map[spindle.StatusKind]int) 76 + for _, w := range p.Statuses { 77 + counts[w.Latest().Status] += 1 78 + } 79 + 80 + total := len(p.Statuses) 81 + 82 + var result []string 83 + // finish states first, followed by start states 84 + states := append(spindle.FinishStates[:], spindle.StartStates[:]...) 85 + for _, state := range states { 86 + if count, ok := counts[state]; ok { 87 + result = append(result, fmt.Sprintf("%d/%d %s", count, total, state.String())) 88 + } 89 + } 90 + 91 + return strings.Join(result, ", ") 92 } 93 94 func (p Pipeline) Counts() map[string]int {
+2 -2
appview/oauth/handler.go
··· 25 26 r.Get("/oauth/client-metadata.json", o.clientMetadata) 27 r.Get("/oauth/jwks.json", o.jwks) 28 - r.Get("/oauth/callback", o.Callback) 29 return r 30 } 31 ··· 51 } 52 } 53 54 - func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) { 55 ctx := r.Context() 56 l := o.Logger.With("query", r.URL.Query()) 57
··· 25 26 r.Get("/oauth/client-metadata.json", o.clientMetadata) 27 r.Get("/oauth/jwks.json", o.jwks) 28 + r.Get("/oauth/callback", o.callback) 29 return r 30 } 31 ··· 51 } 52 } 53 54 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 55 ctx := r.Context() 56 l := o.Logger.With("query", r.URL.Query()) 57
-10
appview/oauth/session.go
··· 1 - package oauth 2 - 3 - import ( 4 - "net/http" 5 - 6 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 7 - ) 8 - 9 - func (o *OAuth) SaveSession2(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) { 10 - }
···
+20 -7
appview/pages/funcmap.go
··· 334 }, 335 "deref": func(v any) any { 336 val := reflect.ValueOf(v) 337 - if val.Kind() == reflect.Ptr && !val.IsNil() { 338 return val.Elem().Interface() 339 } 340 return nil ··· 366 return p.AvatarUrl(handle, "") 367 }, 368 "langColor": enry.GetColor, 369 - "layoutSide": func() string { 370 - return "col-span-1 md:col-span-2 lg:col-span-3" 371 - }, 372 - "layoutCenter": func() string { 373 - return "col-span-1 md:col-span-8 lg:col-span-6" 374 - }, 375 376 "normalizeForHtmlId": func(s string) string { 377 normalized := strings.ReplaceAll(s, ":", "_") 378 normalized = strings.ReplaceAll(normalized, ".", "_")
··· 334 }, 335 "deref": func(v any) any { 336 val := reflect.ValueOf(v) 337 + if val.Kind() == reflect.Pointer && !val.IsNil() { 338 return val.Elem().Interface() 339 } 340 return nil ··· 366 return p.AvatarUrl(handle, "") 367 }, 368 "langColor": enry.GetColor, 369 + "reverse": func(s any) any { 370 + if s == nil { 371 + return nil 372 + } 373 + 374 + v := reflect.ValueOf(s) 375 + 376 + if v.Kind() != reflect.Slice { 377 + return s 378 + } 379 + 380 + length := v.Len() 381 + reversed := reflect.MakeSlice(v.Type(), length, length) 382 383 + for i := range length { 384 + reversed.Index(i).Set(v.Index(length - 1 - i)) 385 + } 386 + 387 + return reversed.Interface() 388 + }, 389 "normalizeForHtmlId": func(s string) string { 390 normalized := strings.ReplaceAll(s, ":", "_") 391 normalized = strings.ReplaceAll(normalized, ".", "_")
+2
appview/pages/pages.go
··· 1103 MergeCheck types.MergeCheckResponse 1104 ResubmitCheck ResubmitResult 1105 Pipelines map[string]models.Pipeline 1106 1107 OrderedReactionKinds []models.ReactionKind 1108 Reactions map[models.ReactionKind]models.ReactionDisplayData
··· 1103 MergeCheck types.MergeCheckResponse 1104 ResubmitCheck ResubmitResult 1105 Pipelines map[string]models.Pipeline 1106 + Diff *types.NiceDiff 1107 + DiffOpts types.DiffOpts 1108 1109 OrderedReactionKinds []models.ReactionKind 1110 Reactions map[models.ReactionKind]models.ReactionDisplayData
+1
appview/pages/templates/fragments/tabSelector.html
··· 9 {{ range $index, $value := $all }} 10 {{ $isActive := eq $value.Key $active }} 11 <a href="?{{ $name }}={{ $value.Key }}" 12 {{ if $include }} 13 hx-get="?{{ $name }}={{ $value.Key }}" 14 hx-include="{{ $include }}"
··· 9 {{ range $index, $value := $all }} 10 {{ $isActive := eq $value.Key $active }} 11 <a href="?{{ $name }}={{ $value.Key }}" 12 + hx-boost=true 13 {{ if $include }} 14 hx-get="?{{ $name }}={{ $value.Key }}" 15 hx-include="{{ $include }}"
+1 -1
appview/pages/templates/layouts/repobase.html
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 p-2 dark:text-white"> 5 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 <!-- left items --> 7 <div class="flex flex-col gap-2">
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "content" }} 4 + <section id="repo-header" class="mb-2 py-2 px-4 dark:text-white"> 5 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 <!-- left items --> 7 <div class="flex flex-col gap-2">
+1 -18
appview/pages/templates/repo/commit.html
··· 116 {{ block "content" . }}{{ end }} 117 {{ end }} 118 119 - {{ block "contentAfterLayout" . }} 120 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 121 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 122 - {{ block "contentAfterLeft" . }} {{ end }} 123 - </div> 124 - <main class="col-span-1 md:col-span-10"> 125 - {{ block "contentAfter" . }}{{ end }} 126 - </main> 127 - </div> 128 - {{ end }} 129 </div> 130 {{ end }} 131 ··· 139 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 140 {{end}} 141 142 - {{ define "contentAfterLeft" }} 143 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 144 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 145 - </div> 146 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 147 - {{ template "repo/fragments/diffChangedFiles" .Diff }} 148 - </div> 149 - {{end}}
··· 116 {{ block "content" . }}{{ end }} 117 {{ end }} 118 119 + {{ block "contentAfter" . }}{{ end }} 120 </div> 121 {{ end }} 122 ··· 130 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 131 {{end}} 132
+1 -19
appview/pages/templates/repo/compare/compare.html
··· 22 {{ block "content" . }}{{ end }} 23 {{ end }} 24 25 - {{ block "contentAfterLayout" . }} 26 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 - {{ block "contentAfterLeft" . }} {{ end }} 29 - </div> 30 - <main class="col-span-1 md:col-span-10"> 31 - {{ block "contentAfter" . }}{{ end }} 32 - </main> 33 - </div> 34 - {{ end }} 35 </div> 36 {{ end }} 37 ··· 44 {{ define "contentAfter" }} 45 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 46 {{end}} 47 - 48 - {{ define "contentAfterLeft" }} 49 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 - </div> 52 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 53 - {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 - </div> 55 - {{end}}
··· 22 {{ block "content" . }}{{ end }} 23 {{ end }} 24 25 + {{ block "contentAfter" . }}{{ end }} 26 </div> 27 {{ end }} 28 ··· 35 {{ define "contentAfter" }} 36 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 37 {{end}}
+113 -36
appview/pages/templates/repo/fragments/diff.html
··· 1 {{ define "repo/fragments/diff" }} 2 {{ $diff := index . 0 }} 3 {{ $opts := index . 1 }} 4 ··· 15 <p>No differences found between the selected revisions.</p> 16 </div> 17 {{ else }} 18 - {{ range $idx, $hunk := $diff }} 19 - {{ with $hunk }} 20 - <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 21 - <summary class="list-none cursor-pointer sticky top-0"> 22 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 23 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 24 - <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 25 - <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 26 - {{ template "repo/fragments/diffStatPill" .Stats }} 27 28 - <div class="flex gap-2 items-center overflow-x-auto"> 29 - {{ if .IsDelete }} 30 - {{ .Name.Old }} 31 - {{ else if (or .IsCopy .IsRename) }} 32 - {{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }} 33 - {{ else }} 34 - {{ .Name.New }} 35 - {{ end }} 36 - </div> 37 - </div> 38 - </div> 39 - </summary> 40 41 - <div class="transition-all duration-700 ease-in-out"> 42 - {{ if .IsBinary }} 43 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 44 - This is a binary file and will not be displayed. 45 - </p> 46 - {{ else }} 47 - {{ if $isSplit }} 48 - {{- template "repo/fragments/splitDiff" .Split -}} 49 {{ else }} 50 - {{- template "repo/fragments/unifiedDiff" . -}} 51 {{ end }} 52 - {{- end -}} 53 </div> 54 - </details> 55 - {{ end }} 56 - {{ end }} 57 - {{ end }} 58 - </div> 59 {{ end }}
··· 1 {{ define "repo/fragments/diff" }} 2 + <style> 3 + #filesToggle:checked ~ div label[for="filesToggle"] .show-text { display: none; } 4 + #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; } 5 + #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; } 6 + #filesToggle:checked ~ div div#files { width: 10vw; margin-right: 1rem; } 7 + #filesToggle:not(:checked) ~ div div#files { width: 0; display: hidden; margin-right: 0; } 8 + </style> 9 + 10 + {{ template "diffTopbar" . }} 11 + {{ block "diffLayout" . }} {{ end }} 12 + {{ end }} 13 + 14 + {{ define "diffTopbar" }} 15 + {{ $diff := index . 0 }} 16 + {{ $opts := index . 1 }} 17 + 18 + {{ block "filesCheckbox" $ }} {{ end }} 19 + {{ block "subsCheckbox" $ }} {{ end }} 20 + 21 + <!-- top bar --> 22 + <div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12 p-2"> 23 + <!-- left panel toggle --> 24 + {{ template "filesToggle" . }} 25 + 26 + <!-- stats --> 27 + {{ template "repo/fragments/diffStatPill" $diff.Stat }} 28 + {{ $diff.Stat.FilesChanged }} changed file{{ if ne $diff.Stat.FilesChanged 1 }}s{{ end }} 29 + 30 + <!-- spacer --> 31 + <div class="flex-grow"></div> 32 + 33 + <!-- diff options --> 34 + {{ template "repo/fragments/diffOpts" $opts }} 35 + 36 + <!-- right panel toggle --> 37 + {{ block "subsToggle" $ }} {{ end }} 38 + </div> 39 + 40 + {{ end }} 41 + 42 + {{ define "diffLayout" }} 43 + {{ $diff := index . 0 }} 44 + {{ $opts := index . 1 }} 45 + 46 + <div class="flex col-span-full"> 47 + <!-- Left panel (same for both desktop and mobile) --> 48 + <div id="files" class="w-0 overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 49 + {{ template "repo/fragments/diffChangedFiles" $diff }} 50 + </div> 51 + 52 + <!-- Main content --> 53 + <div class="flex-1 min-w-0 sticky top-12 pb-12"> 54 + {{ template "diffFiles" (list $diff $opts) }} 55 + </div> 56 + 57 + </div> 58 + {{ end }} 59 + 60 + {{ define "diffFiles" }} 61 {{ $diff := index . 0 }} 62 {{ $opts := index . 1 }} 63 ··· 74 <p>No differences found between the selected revisions.</p> 75 </div> 76 {{ else }} 77 + {{ range $idx, $file := $diff }} 78 + {{ template "diffFile" (list $idx $file $isSplit) }} 79 + {{ end }} 80 + {{ end }} 81 + </div> 82 + {{ end }} 83 84 + {{ define "diffFile" }} 85 + {{ $idx := index . 0 }} 86 + {{ $file := index . 1 }} 87 + {{ $isSplit := index . 2 }} 88 + {{ with $file }} 89 + <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 90 + <summary class="list-none cursor-pointer sticky top-12 group-open:border-b border-gray-200 dark:border-gray-700"> 91 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 92 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 93 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 94 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 95 + {{ template "repo/fragments/diffStatPill" .Stats }} 96 97 + <div class="flex gap-2 items-center overflow-x-auto"> 98 + {{ if .IsDelete }} 99 + {{ .Name.Old }} 100 + {{ else if (or .IsCopy .IsRename) }} 101 + {{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }} 102 {{ else }} 103 + {{ .Name.New }} 104 {{ end }} 105 + </div> 106 </div> 107 + </div> 108 + </summary> 109 + 110 + <div class="transition-all duration-700 ease-in-out"> 111 + {{ if .IsBinary }} 112 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 113 + This is a binary file and will not be displayed. 114 + </p> 115 + {{ else }} 116 + {{ if $isSplit }} 117 + {{- template "repo/fragments/splitDiff" .Split -}} 118 + {{ else }} 119 + {{- template "repo/fragments/unifiedDiff" . -}} 120 + {{ end }} 121 + {{- end -}} 122 + </div> 123 + </details> 124 + {{ end }} 125 + {{ end }} 126 + 127 + {{ define "filesCheckbox" }} 128 + <input type="checkbox" id="filesToggle" class="peer/files hidden" checked/> 129 + {{ end }} 130 + 131 + {{ define "filesToggle" }} 132 + <label for="filesToggle" class="hidden md:inline-flex items-center justify-center rounded cursor-pointer text-normal font-normal normalcase"> 133 + <span class="show-text">{{ i "panel-left-open" "size-4" }}</span> 134 + <span class="hide-text">{{ i "panel-left-close" "size-4" }}</span> 135 + </label> 136 {{ end }}
+1 -8
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 {{ define "repo/fragments/diffChangedFiles" }} 2 - {{ $stat := .Stat }} 3 {{ $fileTree := fileTree .ChangedFiles }} 4 <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 - <div class="diff-stat"> 6 - <div class="flex gap-2 items-center"> 7 - <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 8 - {{ template "repo/fragments/diffStatPill" $stat }} 9 - </div> 10 - {{ template "repo/fragments/fileTree" $fileTree }} 11 - </div> 12 </section> 13 {{ end }}
··· 1 {{ define "repo/fragments/diffChangedFiles" }} 2 {{ $fileTree := fileTree .ChangedFiles }} 3 <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 4 + {{ template "repo/fragments/fileTree" $fileTree }} 5 </section> 6 {{ end }}
+22 -25
appview/pages/templates/repo/fragments/diffOpts.html
··· 1 {{ define "repo/fragments/diffOpts" }} 2 - <section class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 3 - <strong class="text-sm uppercase dark:text-gray-200">options</strong> 4 - {{ $active := "unified" }} 5 - {{ if .Split }} 6 - {{ $active = "split" }} 7 - {{ end }} 8 9 - {{ $unified := 10 - (dict 11 - "Key" "unified" 12 - "Value" "unified" 13 - "Icon" "square-split-vertical" 14 - "Meta" "") }} 15 - {{ $split := 16 - (dict 17 - "Key" "split" 18 - "Value" "split" 19 - "Icon" "square-split-horizontal" 20 - "Meta" "") }} 21 - {{ $values := list $unified $split }} 22 23 - {{ template "fragments/tabSelector" 24 - (dict 25 - "Name" "diff" 26 - "Values" $values 27 - "Active" $active) }} 28 - </section> 29 {{ end }} 30
··· 1 {{ define "repo/fragments/diffOpts" }} 2 + {{ $active := "unified" }} 3 + {{ if .Split }} 4 + {{ $active = "split" }} 5 + {{ end }} 6 7 + {{ $unified := 8 + (dict 9 + "Key" "unified" 10 + "Value" "unified" 11 + "Icon" "square-split-vertical" 12 + "Meta" "") }} 13 + {{ $split := 14 + (dict 15 + "Key" "split" 16 + "Value" "split" 17 + "Icon" "square-split-horizontal" 18 + "Meta" "") }} 19 + {{ $values := list $unified $split }} 20 21 + {{ template "fragments/tabSelector" 22 + (dict 23 + "Name" "diff" 24 + "Values" $values 25 + "Active" $active) }} 26 {{ end }} 27
+2 -3
appview/pages/templates/repo/fragments/interdiff.html
··· 1 {{ define "repo/fragments/interdiff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $x := index . 1 }} 4 - {{ $opts := index . 2 }} 5 {{ $fileTree := fileTree $x.AffectedFiles }} 6 {{ $diff := $x.Files }} 7 {{ $last := sub (len $diff) 1 }}
··· 1 {{ define "repo/fragments/interdiff" }} 2 + {{ $x := index . 0 }} 3 + {{ $opts := index . 1 }} 4 {{ $fileTree := fileTree $x.AffectedFiles }} 5 {{ $diff := $x.Files }} 6 {{ $last := sub (len $diff) 1 }}
+1 -1
appview/pages/templates/repo/fragments/splitDiff.html
··· 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
··· 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 + {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200 target:dark:bg-yellow-700 scroll-mt-48" -}} 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
+1 -1
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 {{- $lineNrSepStyle1 := "" -}} 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 - {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
··· 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 {{- $lineNrSepStyle1 := "" -}} 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 + {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200 target:dark:bg-yellow-700 scroll-mt-48" -}} 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
+35 -22
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 {{ define "repo/issues/fragments/commentList" }} 2 - <div class="flex flex-col gap-8"> 3 {{ range $item := .CommentList }} 4 {{ template "commentListing" (list $ .) }} 5 {{ end }} ··· 19 <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 {{ template "topLevelComment" $params }} 21 22 - <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 23 {{ range $index, $reply := $comment.Replies }} 24 - <div class="relative "> 25 - <!-- Horizontal connector --> 26 - <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 - 28 - <div class="pl-2"> 29 - {{ 30 - template "replyComment" 31 - (dict 32 - "RepoInfo" $root.RepoInfo 33 - "LoggedInUser" $root.LoggedInUser 34 - "Issue" $root.Issue 35 - "Comment" $reply) 36 - }} 37 - </div> 38 </div> 39 {{ end }} 40 </div> ··· 44 {{ end }} 45 46 {{ define "topLevelComment" }} 47 - <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 - {{ template "repo/issues/fragments/issueCommentBody" . }} 50 </div> 51 {{ end }} 52 53 {{ define "replyComment" }} 54 - <div class="p-4 w-full mx-auto overflow-hidden"> 55 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 - {{ template "repo/issues/fragments/issueCommentBody" . }} 57 </div> 58 {{ end }}
··· 1 {{ define "repo/issues/fragments/commentList" }} 2 + <div class="flex flex-col gap-4"> 3 {{ range $item := .CommentList }} 4 {{ template "commentListing" (list $ .) }} 5 {{ end }} ··· 19 <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 {{ template "topLevelComment" $params }} 21 22 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 23 {{ range $index, $reply := $comment.Replies }} 24 + <div class="-ml-4"> 25 + {{ 26 + template "replyComment" 27 + (dict 28 + "RepoInfo" $root.RepoInfo 29 + "LoggedInUser" $root.LoggedInUser 30 + "Issue" $root.Issue 31 + "Comment" $reply) 32 + }} 33 </div> 34 {{ end }} 35 </div> ··· 39 {{ end }} 40 41 {{ define "topLevelComment" }} 42 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 43 + <div class="flex-shrink-0"> 44 + <img 45 + src="{{ tinyAvatar .Comment.Did }}" 46 + alt="" 47 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 48 + /> 49 + </div> 50 + <div class="flex-1 min-w-0"> 51 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 52 + {{ template "repo/issues/fragments/issueCommentBody" . }} 53 + </div> 54 </div> 55 {{ end }} 56 57 {{ define "replyComment" }} 58 + <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 59 + <div class="flex-shrink-0"> 60 + <img 61 + src="{{ tinyAvatar .Comment.Did }}" 62 + alt="" 63 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 64 + /> 65 + </div> 66 + <div class="flex-1 min-w-0"> 67 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 68 + {{ template "repo/issues/fragments/issueCommentBody" . }} 69 + </div> 70 </div> 71 {{ end }}
-63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 - {{ define "repo/issues/fragments/globalIssueListing" }} 2 - <div class="flex flex-col gap-2"> 3 - {{ range .Issues }} 4 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 - <div class="pb-2 mb-3"> 6 - <div class="flex items-center gap-3 mb-2"> 7 - <a 8 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 - class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 - > 11 - {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 - </a> 13 - </div> 14 - <a 15 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 - class="no-underline hover:underline" 17 - > 18 - {{ .Title | description }} 19 - <span class="text-gray-500">#{{ .IssueId }}</span> 20 - </a> 21 - </div> 22 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 - {{ $icon := "ban" }} 25 - {{ $state := "closed" }} 26 - {{ if .Open }} 27 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 - {{ $icon = "circle-dot" }} 29 - {{ $state = "open" }} 30 - {{ end }} 31 - 32 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 - <span class="text-white dark:text-white">{{ $state }}</span> 35 - </span> 36 - 37 - <span class="ml-1"> 38 - {{ template "user/fragments/picHandleLink" .Did }} 39 - </span> 40 - 41 - <span class="before:content-['ยท']"> 42 - {{ template "repo/fragments/time" .Created }} 43 - </span> 44 - 45 - <span class="before:content-['ยท']"> 46 - {{ $s := "s" }} 47 - {{ if eq (len .Comments) 1 }} 48 - {{ $s = "" }} 49 - {{ end }} 50 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 - </span> 52 - 53 - {{ $state := .Labels }} 54 - {{ range $k, $d := $.LabelDefs }} 55 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 - {{ end }} 58 - {{ end }} 59 - </div> 60 - </div> 61 - {{ end }} 62 - </div> 63 - {{ end }}
···
+2 -1
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 {{ template "hats" $ }} 5 {{ template "timestamp" . }} 6 {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 {{ if and $isCommentOwner (not .Comment.Deleted) }}
··· 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ resolve .Comment.Did }} 4 {{ template "hats" $ }} 5 + <span class="before:content-['ยท']"></span> 6 {{ template "timestamp" . }} 7 {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 8 {{ if and $isCommentOwner (not .Comment.Deleted) }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 21 {{ $state = "open" }} 22 {{ end }} 23 24 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 - <span class="text-white dark:text-white">{{ $state }}</span> 27 </span> 28 29 <span class="ml-1">
··· 21 {{ $state = "open" }} 22 {{ end }} 23 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 25 {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white text-sm">{{ $state }}</span> 27 </span> 28 29 <span class="ml-1">
+1 -1
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 18 <textarea 19 name="body" 20 id="body" 21 - rows="6" 22 class="w-full resize-y" 23 placeholder="Describe your issue. Markdown is supported." 24 >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
··· 18 <textarea 19 name="body" 20 id="body" 21 + rows="15" 22 class="w-full resize-y" 23 placeholder="Describe your issue. Markdown is supported." 24 >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
+3 -3
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 - <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 {{ if .LoggedInUser }} 4 <img 5 src="{{ tinyAvatar .LoggedInUser.Did }}" 6 alt="" 7 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 8 /> 9 {{ end }} 10 <input 11 - class="w-full py-2 border-none focus:outline-none" 12 placeholder="Leave a reply..." 13 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 hx-trigger="focus"
··· 1 {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 {{ if .LoggedInUser }} 4 <img 5 src="{{ tinyAvatar .LoggedInUser.Did }}" 6 alt="" 7 + class="rounded-full size-8 mr-1 border-2 border-gray-300 dark:border-gray-700" 8 /> 9 {{ end }} 10 <input 11 + class="w-full p-0 border-none focus:outline-none" 12 placeholder="Leave a reply..." 13 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 hx-trigger="focus"
+5 -5
appview/pages/templates/repo/issues/issue.html
··· 58 {{ $icon = "circle-dot" }} 59 {{ end }} 60 <div class="inline-flex items-center gap-2"> 61 - <div id="state" 62 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 63 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 64 - <span class="text-white">{{ .Issue.State }}</span> 65 - </div> 66 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 67 opened by 68 {{ template "user/fragments/picHandleLink" .Issue.Did }}
··· 58 {{ $icon = "circle-dot" }} 59 {{ end }} 60 <div class="inline-flex items-center gap-2"> 61 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 62 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 63 + <span class="text-white dark:text-white text-sm">{{ .Issue.State }}</span> 64 + </span> 65 + 66 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 67 opened by 68 {{ template "user/fragments/picHandleLink" .Issue.Did }}
+60 -69
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 1 {{ define "repo/pipelines/fragments/pipelineSymbol" }} 2 - <div class="cursor-pointer"> 3 - {{ $c := .Counts }} 4 - {{ $statuses := .Statuses }} 5 - {{ $total := len $statuses }} 6 - {{ $success := index $c "success" }} 7 - {{ $fail := index $c "failed" }} 8 - {{ $timeout := index $c "timeout" }} 9 - {{ $empty := eq $total 0 }} 10 - {{ $allPass := eq $success $total }} 11 - {{ $allFail := eq $fail $total }} 12 - {{ $allTimeout := eq $timeout $total }} 13 - 14 - {{ if $empty }} 15 - <div class="flex gap-1 items-center"> 16 - {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 17 - <span>0/{{ $total }}</span> 18 - </div> 19 - {{ else if $allPass }} 20 - <div class="flex gap-1 items-center"> 21 - {{ i "check" "size-4 text-green-600" }} 22 - <span>{{ $total }}/{{ $total }}</span> 23 - </div> 24 - {{ else if $allFail }} 25 - <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-500" }} 27 - <span>0/{{ $total }}</span> 28 - </div> 29 - {{ else if $allTimeout }} 30 - <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-500" }} 32 - <span>0/{{ $total }}</span> 33 - </div> 34 {{ else }} 35 - {{ $radius := f64 8 }} 36 - {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 37 - {{ $offset := 0.0 }} 38 - <div class="flex gap-1 items-center"> 39 - <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 40 - <circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/> 41 42 - {{ range $kind, $count := $c }} 43 - {{ $color := "" }} 44 - {{ if or (eq $kind "pending") (eq $kind "running") }} 45 - {{ $color = "#eab308" }} {{/* amber-500 */}} 46 - {{ else if eq $kind "success" }} 47 - {{ $color = "#10b981" }} {{/* green-500 */}} 48 - {{ else if eq $kind "cancelled" }} 49 - {{ $color = "#6b7280" }} {{/* gray-500 */}} 50 - {{ else if eq $kind "timeout" }} 51 - {{ $color = "#fb923c" }} {{/* orange-400 */}} 52 - {{ else }} 53 - {{ $color = "#ef4444" }} {{/* red-500 for failed or unknown */}} 54 - {{ end }} 55 56 - {{ $percent := divf64 (f64 $count) (f64 $total) }} 57 - {{ $length := mulf64 $percent $circumference }} 58 - 59 - <circle 60 - cx="10" cy="10" r="{{ $radius }}" 61 - fill="none" 62 - stroke="{{ $color }}" 63 - stroke-width="2" 64 - stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 65 - stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 66 - /> 67 - {{ $offset = addf64 $offset $length }} 68 - {{ end }} 69 - </svg> 70 - <span>{{ $success }}/{{ $total }}</span> 71 - </div> 72 - {{ end }} 73 - </div> 74 {{ end }}
··· 1 {{ define "repo/pipelines/fragments/pipelineSymbol" }} 2 + <div class="cursor-pointer flex gap-2 items-center"> 3 + {{ template "symbol" .Pipeline }} 4 + {{ if .ShortSummary }} 5 + {{ .Pipeline.ShortStatusSummary }} 6 {{ else }} 7 + {{ .Pipeline.LongStatusSummary }} 8 + {{ end }} 9 + </div> 10 + {{ end }} 11 12 + {{ define "symbol" }} 13 + {{ $c := .Counts }} 14 + {{ $statuses := .Statuses }} 15 + {{ $total := len $statuses }} 16 + {{ $success := index $c "success" }} 17 + {{ $fail := index $c "failed" }} 18 + {{ $timeout := index $c "timeout" }} 19 + {{ $empty := eq $total 0 }} 20 + {{ $allPass := eq $success $total }} 21 + {{ $allFail := eq $fail $total }} 22 + {{ $allTimeout := eq $timeout $total }} 23 24 + {{ if $empty }} 25 + {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 26 + {{ else if $allPass }} 27 + {{ i "check" "size-4 text-green-600 dark:text-green-500" }} 28 + {{ else if $allFail }} 29 + {{ i "x" "size-4 text-red-600 dark:text-red-500" }} 30 + {{ else if $allTimeout }} 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 + {{ else }} 33 + {{ $radius := f64 8 }} 34 + {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 35 + {{ $offset := 0.0 }} 36 + <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 37 + <circle cx="10" cy="10" r="{{ $radius }}" fill="none" class="stroke-gray-200 dark:stroke-gray-700" stroke-width="2"/> 38 + {{ range $kind, $count := $c }} 39 + {{ $colorClass := "" }} 40 + {{ if or (eq $kind "pending") (eq $kind "running") }} 41 + {{ $colorClass = "stroke-yellow-600 dark:stroke-yellow-500" }} 42 + {{ else if eq $kind "success" }} 43 + {{ $colorClass = "stroke-green-600 dark:stroke-green-500" }} 44 + {{ else if eq $kind "cancelled" }} 45 + {{ $colorClass = "stroke-gray-600 dark:stroke-gray-500" }} 46 + {{ else if eq $kind "timeout" }} 47 + {{ $colorClass = "stroke-orange-600 dark:stroke-orange-500" }} 48 + {{ else }} 49 + {{ $colorClass = "stroke-red-600 dark:stroke-red-500" }} 50 + {{ end }} 51 + {{ $percent := divf64 (f64 $count) (f64 $total) }} 52 + {{ $length := mulf64 $percent $circumference }} 53 + <circle 54 + cx="10" cy="10" r="{{ $radius }}" 55 + fill="none" 56 + class="{{ $colorClass }}" 57 + stroke-width="2" 58 + stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 59 + stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 60 + /> 61 + {{ $offset = addf64 $offset $length }} 62 + {{ end }} 63 + </svg> 64 + {{ end }} 65 {{ end }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
··· 4 <div class="relative inline-block"> 5 <details class="relative"> 6 <summary class="cursor-pointer list-none"> 7 - {{ template "repo/pipelines/fragments/pipelineSymbol" .Pipeline }} 8 </summary> 9 {{ template "repo/pipelines/fragments/tooltip" $ }} 10 </details>
··· 4 <div class="relative inline-block"> 5 <details class="relative"> 6 <summary class="cursor-pointer list-none"> 7 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 8 </summary> 9 {{ template "repo/pipelines/fragments/tooltip" $ }} 10 </details>
+17 -17
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative"> 26 <button 27 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 hx-target="#actions-{{$roundNumber}}" 29 hx-swap="outerHtml" 30 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 - {{ i "message-square-plus" "w-4 h-4" }} 32 - <span>comment</span> 33 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 34 </button> 35 {{ if .BranchDeleteStatus }} 36 <button 37 hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 hx-swap="none" 40 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 {{ i "git-branch" "w-4 h-4" }} 42 <span>delete branch</span> 43 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 52 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 hx-swap="none" 54 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 - class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 56 - {{ i "git-merge" "w-4 h-4" }} 57 - <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 58 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 </button> 60 {{ end }} 61 ··· 74 {{ end }} 75 76 hx-disabled-elt="#resubmitBtn" 77 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 78 79 {{ if $disabled }} 80 title="Update this branch to resubmit this pull request" ··· 82 title="Resubmit this pull request" 83 {{ end }} 84 > 85 - {{ i "rotate-ccw" "w-4 h-4" }} 86 - <span>resubmit</span> 87 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 </button> 89 {{ end }} 90 ··· 92 <button 93 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 hx-swap="none" 95 - class="btn p-2 flex items-center gap-2 group"> 96 - {{ i "ban" "w-4 h-4" }} 97 - <span>close</span> 98 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 </button> 100 {{ end }} 101 ··· 103 <button 104 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 hx-swap="none" 106 - class="btn p-2 flex items-center gap-2 group"> 107 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 108 - <span>reopen</span> 109 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 110 </button> 111 {{ end }} 112 </div>
··· 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative p-2"> 26 <button 27 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 hx-target="#actions-{{$roundNumber}}" 29 hx-swap="outerHtml" 30 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 + {{ i "message-square-plus" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 32 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + comment 34 </button> 35 {{ if .BranchDeleteStatus }} 36 <button 37 hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 hx-swap="none" 40 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 {{ i "git-branch" "w-4 h-4" }} 42 <span>delete branch</span> 43 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 52 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 hx-swap="none" 54 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 + class="btn-flat p-2 flex items-center gap-2 group" {{ $disabled }}> 56 + {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 57 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + merge{{if $stackCount}} {{$stackCount}}{{end}} 59 </button> 60 {{ end }} 61 ··· 74 {{ end }} 75 76 hx-disabled-elt="#resubmitBtn" 77 + class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 78 79 {{ if $disabled }} 80 title="Update this branch to resubmit this pull request" ··· 82 title="Resubmit this pull request" 83 {{ end }} 84 > 85 + {{ i "rotate-ccw" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 + resubmit 88 </button> 89 {{ end }} 90 ··· 92 <button 93 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 hx-swap="none" 95 + class="btn-flat p-2 flex items-center gap-2 group"> 96 + {{ i "ban" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 97 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 98 + close 99 </button> 100 {{ end }} 101 ··· 103 <button 104 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 hx-swap="none" 106 + class="btn-flat p-2 flex items-center gap-2 group"> 107 + {{ i "refresh-ccw-dot" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 108 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 109 + reopen 110 </button> 111 {{ end }} 112 </div>
+6 -7
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 - <header class="pb-4"> 3 <h1 class="text-2xl dark:text-white"> 4 {{ .Pull.Title | description }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> ··· 17 {{ $icon = "git-merge" }} 18 {{ end }} 19 20 - <section class="mt-2"> 21 <div class="flex items-center gap-2"> 22 - <div 23 - id="state" 24 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 25 > 26 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 27 <span class="text-white">{{ .Pull.State.String }}</span> 28 - </div> 29 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 opened by 31 {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 + <header class="pb-2"> 3 <h1 class="text-2xl dark:text-white"> 4 {{ .Pull.Title | description }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> ··· 17 {{ $icon = "git-merge" }} 18 {{ end }} 19 20 + <section> 21 <div class="flex items-center gap-2"> 22 + <span 23 + class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm" 24 > 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white" }} 26 <span class="text-white">{{ .Pull.State.String }}</span> 27 + </span> 28 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 29 opened by 30 {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
+39 -24
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 <div 3 id="pull-comment-card-{{ .RoundNumber }}" 4 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 - <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ resolve .LoggedInUser.Did }} 7 - </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 - hx-indicator="#create-comment-spinner" 11 hx-swap="none" 12 - class="w-full flex flex-wrap gap-2" 13 > 14 <textarea 15 name="body" 16 class="w-full p-2 rounded border border-gray-200" 17 placeholder="Add to the discussion..."></textarea 18 > 19 - <button type="submit" class="btn flex items-center gap-2"> 20 - {{ i "message-square" "w-4 h-4" }} 21 - <span>comment</span> 22 - <span id="create-comment-spinner" class="group"> 23 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 24 - </span> 25 - </button> 26 - <button 27 - type="button" 28 - class="btn flex items-center gap-2 group" 29 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 30 - hx-swap="outerHTML" 31 - hx-target="#pull-comment-card-{{ .RoundNumber }}" 32 - > 33 - {{ i "x" "w-4 h-4" }} 34 - <span>cancel</span> 35 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 - </button> 37 <div id="pull-comment"></div> 38 </form> 39 </div> 40 {{ end }}
··· 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 <div 3 id="pull-comment-card-{{ .RoundNumber }}" 4 + class="w-full flex flex-col gap-2"> 5 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 6 <form 7 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 8 hx-swap="none" 9 + hx-on::after-request="if(event.detail.successful) this.reset()" 10 + hx-disabled-elt="#reply-{{ .RoundNumber }}" 11 + class="w-full flex flex-wrap gap-2 group" 12 > 13 <textarea 14 name="body" 15 class="w-full p-2 rounded border border-gray-200" 16 + rows=8 17 placeholder="Add to the discussion..."></textarea 18 > 19 + {{ template "replyActions" . }} 20 <div id="pull-comment"></div> 21 </form> 22 </div> 23 {{ end }} 24 + 25 + {{ define "replyActions" }} 26 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm w-full"> 27 + {{ template "cancel" . }} 28 + {{ template "reply" . }} 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "cancel" }} 33 + <button 34 + type="button" 35 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 36 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 37 + hx-swap="outerHTML" 38 + hx-target="#actions-{{.RoundNumber}}" 39 + > 40 + {{ i "x" "w-4 h-4" }} 41 + <span>cancel</span> 42 + </button> 43 + {{ end }} 44 + 45 + {{ define "reply" }} 46 + <button 47 + type="submit" 48 + id="reply-{{ .RoundNumber }}" 49 + class="btn-create flex items-center gap-2"> 50 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + reply 53 + </button> 54 + {{ end }} 55 +
+20
appview/pages/templates/repo/pulls/fragments/replyPullCommentPlaceholder.html
···
··· 1 + {{ define "repo/pulls/fragments/replyPullCommentPlaceholder" }} 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full size-8 mr-1 border-2 border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full p-0 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .Submission.ID }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 {{ $commentCount := len $lastSubmission.Comments }} 20 {{ if and $pipeline $pipeline.Id }} 21 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 22 <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 {{ end }} 24 <span>
··· 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 {{ $commentCount := len $lastSubmission.Comments }} 20 {{ if and $pipeline $pipeline.Id }} 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 22 <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 {{ end }} 24 <span>
+2 -20
appview/pages/templates/repo/pulls/interdiff.html
··· 34 {{ block "content" . }}{{ end }} 35 {{ end }} 36 37 - {{ block "contentAfterLayout" . }} 38 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 39 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 40 - {{ block "contentAfterLeft" . }} {{ end }} 41 - </div> 42 - <main class="col-span-1 md:col-span-10"> 43 - {{ block "contentAfter" . }}{{ end }} 44 - </main> 45 - </div> 46 - {{ end }} 47 </div> 48 {{ end }} 49 50 {{ define "contentAfter" }} 51 - {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 52 - {{end}} 53 - 54 - {{ define "contentAfterLeft" }} 55 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 56 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 57 - </div> 58 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 59 - {{ template "repo/fragments/interdiffFiles" .Interdiff }} 60 - </div> 61 {{end}}
··· 34 {{ block "content" . }}{{ end }} 35 {{ end }} 36 37 + {{ block "contentAfter" . }}{{ end }} 38 </div> 39 {{ end }} 40 41 {{ define "contentAfter" }} 42 + {{ template "repo/fragments/interdiff" (list .Interdiff .DiffOpts) }} 43 {{end}}
+388 -227
appview/pages/templates/repo/pulls/pull.html
··· 6 {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 {{ end }} 8 9 {{ define "repoContentLayout" }} 10 - <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 11 - <div class="col-span-1 md:col-span-8"> 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 13 {{ block "repoContent" . }}{{ end }} 14 </section> 15 {{ block "repoAfter" . }}{{ end }} 16 </div> 17 - <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 18 {{ template "repo/fragments/labelPanel" 19 (dict "RepoInfo" $.RepoInfo 20 "Defs" $.LabelDefs ··· 26 "Backlinks" $.Backlinks) }} 27 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 28 </div> 29 </div> 30 {{ end }} 31 32 {{ define "repoContent" }} 33 {{ template "repo/pulls/fragments/pullHeader" . }} 34 - 35 {{ if .Pull.IsStacked }} 36 <div class="mt-8"> 37 {{ template "repo/pulls/fragments/pullStack" . }} ··· 40 {{ end }} 41 42 {{ define "repoAfter" }} 43 - <section id="submissions" class="mt-4"> 44 - <div class="flex flex-col gap-4"> 45 - {{ block "submissions" . }} {{ end }} 46 </div> 47 - </section> 48 49 - <div id="pull-close"></div> 50 - <div id="pull-reopen"></div> 51 {{ end }} 52 53 {{ define "submissions" }} 54 {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 55 - {{ $targetBranch := .Pull.TargetBranch }} 56 - {{ $repoName := .RepoInfo.FullName }} 57 - {{ range $idx, $item := .Pull.Submissions }} 58 - {{ with $item }} 59 - <details {{ if eq $idx $lastIdx }}open{{ end }}> 60 - <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 61 - <div class="flex flex-wrap gap-2 items-stretch"> 62 - <!-- round number --> 63 - <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 64 - <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 65 - </div> 66 - <!-- round summary --> 67 - <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 68 - <span class="gap-1 flex items-center"> 69 - {{ $owner := resolve $.Pull.OwnerDid }} 70 - {{ $re := "re" }} 71 - {{ if eq .RoundNumber 0 }} 72 - {{ $re = "" }} 73 - {{ end }} 74 - <span class="hidden md:inline">{{$re}}submitted</span> 75 - by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 76 - <span class="select-none before:content-['\00B7']"></span> 77 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 78 - <span class="select-none before:content-['ยท']"></span> 79 - {{ $s := "s" }} 80 - {{ if eq (len .Comments) 1 }} 81 - {{ $s = "" }} 82 - {{ end }} 83 - {{ len .Comments }} comment{{$s}} 84 - </span> 85 - </div> 86 87 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 88 - hx-boost="true" 89 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 90 - {{ i "file-diff" "w-4 h-4" }} 91 - <span class="hidden md:inline">diff</span> 92 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 93 - </a> 94 - {{ if ne $idx 0 }} 95 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 96 - hx-boost="true" 97 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 98 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 99 - <span class="hidden md:inline">interdiff</span> 100 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 101 - </a> 102 - {{ end }} 103 - <span id="interdiff-error-{{.RoundNumber}}"></span> 104 - </div> 105 - </summary> 106 107 - {{ if .IsFormatPatch }} 108 - {{ $patches := .AsFormatPatch }} 109 - {{ $round := .RoundNumber }} 110 - <details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm"> 111 - <summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 112 - {{ $s := "s" }} 113 - {{ if eq (len $patches) 1 }} 114 - {{ $s = "" }} 115 - {{ end }} 116 - <div class="group-open:hidden flex items-center gap-2 ml-2"> 117 - {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}} 118 - </div> 119 - <div class="hidden group-open:flex items-center gap-2 ml-2"> 120 - {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}} 121 - </div> 122 - </summary> 123 - {{ range $patches }} 124 - <div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col"> 125 - <div class="flex items-center gap-2"> 126 - {{ i "git-commit-horizontal" "w-4 h-4" }} 127 - <div class="text-sm text-gray-500 dark:text-gray-400"> 128 - <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 129 - {{ $fullRepo := "" }} 130 - {{ if and $.Pull.IsForkBased $.Pull.PullSource.Repo }} 131 - {{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }} 132 - {{ else if $.Pull.IsBranchBased }} 133 - {{ $fullRepo = $.RepoInfo.FullName }} 134 - {{ end }} 135 136 - <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 137 - {{ if $fullRepo }} 138 - <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a> 139 - {{ else }} 140 - <span class="font-mono">{{ slice .SHA 0 8 }}</span> 141 - {{ end }} 142 - </div> 143 - <div class="flex items-center"> 144 - <span>{{ .Title | description }}</span> 145 - {{ if gt (len .Body) 0 }} 146 - <button 147 - class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 148 - hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 149 - > 150 - {{ i "ellipsis" "w-3 h-3" }} 151 - </button> 152 - {{ end }} 153 - </div> 154 - </div> 155 - {{ if gt (len .Body) 0 }} 156 - <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2"> 157 - {{ nl2br .Body }} 158 - </p> 159 - {{ end }} 160 - </div> 161 - {{ end }} 162 - </details> 163 - {{ end }} 164 165 166 - <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 167 - {{ range $cidx, $c := .Comments }} 168 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 169 - {{ if gt $cidx 0 }} 170 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 171 - {{ end }} 172 - <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 173 - {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 174 - <span class="before:content-['ยท']"></span> 175 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 176 - </div> 177 - <div class="prose dark:prose-invert"> 178 - {{ $c.Body | markdown }} 179 - </div> 180 - </div> 181 - {{ end }} 182 183 - {{ block "pipelineStatus" (list $ .) }} {{ end }} 184 185 - {{ if eq $lastIdx .RoundNumber }} 186 - {{ block "mergeStatus" $ }} {{ end }} 187 - {{ block "resubmitStatus" $ }} {{ end }} 188 {{ end }} 189 190 - {{ if $.LoggedInUser }} 191 - {{ template "repo/pulls/fragments/pullActions" 192 - (dict 193 - "LoggedInUser" $.LoggedInUser 194 - "Pull" $.Pull 195 - "RepoInfo" $.RepoInfo 196 - "RoundNumber" .RoundNumber 197 - "MergeCheck" $.MergeCheck 198 - "ResubmitCheck" $.ResubmitCheck 199 - "BranchDeleteStatus" $.BranchDeleteStatus 200 - "Stack" $.Stack) }} 201 {{ else }} 202 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 203 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 204 - sign up 205 - </a> 206 - <span class="text-gray-500 dark:text-gray-400">or</span> 207 - <a href="/login" class="underline">login</a> 208 - to add to the discussion 209 - </div> 210 {{ end }} 211 </div> 212 </details> 213 - {{ end }} 214 {{ end }} 215 {{ end }} 216 217 {{ define "mergeStatus" }} 218 {{ if .Pull.State.IsClosed }} 219 - <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 220 <div class="flex items-center gap-2 text-black dark:text-white"> 221 {{ i "ban" "w-4 h-4" }} 222 <span class="font-medium">closed without merging</span ··· 224 </div> 225 </div> 226 {{ else if .Pull.State.IsMerged }} 227 - <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 228 <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 229 {{ i "git-merge" "w-4 h-4" }} 230 <span class="font-medium">pull request successfully merged</span ··· 232 </div> 233 </div> 234 {{ else if .Pull.State.IsDeleted }} 235 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 236 <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 237 {{ i "git-pull-request-closed" "w-4 h-4" }} 238 <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 239 </div> 240 </div> 241 - {{ else if and .MergeCheck .MergeCheck.Error }} 242 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 243 - <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 244 - {{ i "triangle-alert" "w-4 h-4" }} 245 - <span class="font-medium">{{ .MergeCheck.Error }}</span> 246 - </div> 247 - </div> 248 - {{ else if and .MergeCheck .MergeCheck.IsConflicted }} 249 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 250 - <div class="flex flex-col gap-2 text-red-500 dark:text-red-300"> 251 - <div class="flex items-center gap-2"> 252 - {{ i "triangle-alert" "w-4 h-4" }} 253 - <span class="font-medium">merge conflicts detected</span> 254 - </div> 255 - {{ if gt (len .MergeCheck.Conflicts) 0 }} 256 - <ul class="space-y-1"> 257 - {{ range .MergeCheck.Conflicts }} 258 - {{ if .Filename }} 259 - <li class="flex items-center"> 260 - {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 261 - <span class="font-mono">{{ .Filename }}</span> 262 - </li> 263 - {{ else if .Reason }} 264 - <li class="flex items-center"> 265 - {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 266 - <span>{{.Reason}}</span> 267 - </li> 268 - {{ end }} 269 - {{ end }} 270 - </ul> 271 - {{ end }} 272 - </div> 273 - </div> 274 - {{ else if .MergeCheck }} 275 - <div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 276 - <div class="flex items-center gap-2 text-green-500 dark:text-green-300"> 277 - {{ i "circle-check-big" "w-4 h-4" }} 278 - <span class="font-medium">no conflicts, ready to merge</span> 279 - </div> 280 - </div> 281 {{ end }} 282 {{ end }} 283 284 {{ define "resubmitStatus" }} 285 {{ if .ResubmitCheck.Yes }} 286 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 287 <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 288 {{ i "triangle-alert" "w-4 h-4" }} 289 <span class="font-medium">this branch has been updated, consider resubmitting</span> ··· 292 {{ end }} 293 {{ end }} 294 295 - {{ define "pipelineStatus" }} 296 - {{ $root := index . 0 }} 297 - {{ $submission := index . 1 }} 298 - {{ $pipeline := index $root.Pipelines $submission.SourceRev }} 299 {{ with $pipeline }} 300 {{ $id := .Id }} 301 {{ if .Statuses }} 302 - <div class="max-w-80 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 303 - {{ range $name, $all := .Statuses }} 304 - <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 305 - <div 306 - class="flex gap-2 items-center justify-between p-2"> 307 - {{ $lastStatus := $all.Latest }} 308 - {{ $kind := $lastStatus.Status.String }} 309 310 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 311 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 312 - {{ $name }} 313 - </div> 314 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 315 - <span class="font-bold">{{ $kind }}</span> 316 - {{ if .TimeTaken }} 317 - {{ template "repo/fragments/duration" .TimeTaken }} 318 - {{ else }} 319 - {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 320 - {{ end }} 321 - </div> 322 </div> 323 - </a> 324 - {{ end }} 325 - </div> 326 {{ end }} 327 {{ end }} 328 {{ end }}
··· 6 {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 {{ end }} 8 9 + {{ define "mainLayout" }} 10 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 11 + {{ block "contentLayout" . }} 12 + {{ block "content" . }}{{ end }} 13 + {{ end }} 14 + </div> 15 + <script> 16 + (function() { 17 + const details = document.getElementById('bottomSheet'); 18 + const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; 19 + 20 + // close on mobile initially 21 + if (!isDesktop()) { 22 + details.open = false; 23 + } 24 + 25 + // prevent closing on desktop 26 + details.addEventListener('toggle', function(e) { 27 + if (isDesktop() && !this.open) { 28 + this.open = true; 29 + } 30 + }); 31 + 32 + const mediaQuery = window.matchMedia('(min-width: 768px)'); 33 + mediaQuery.addEventListener('change', function(e) { 34 + if (e.matches) { 35 + // switched to desktop - keep open 36 + details.open = true; 37 + } else { 38 + // switched to mobile - close 39 + details.open = false; 40 + } 41 + }); 42 + })(); 43 + </script> 44 + {{ end }} 45 + 46 {{ define "repoContentLayout" }} 47 + <div class="grid grid-cols-1 md:grid-cols-10 gap-y-2 gap-x-4 w-full"> 48 + <div class="col-span-1 md:col-span-7"> 49 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full"> 50 {{ block "repoContent" . }}{{ end }} 51 </section> 52 {{ block "repoAfter" . }}{{ end }} 53 </div> 54 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 55 {{ template "repo/fragments/labelPanel" 56 (dict "RepoInfo" $.RepoInfo 57 "Defs" $.LabelDefs ··· 63 "Backlinks" $.Backlinks) }} 64 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 65 </div> 66 + 67 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts $) }} 68 </div> 69 {{ end }} 70 71 {{ define "repoContent" }} 72 {{ template "repo/pulls/fragments/pullHeader" . }} 73 {{ if .Pull.IsStacked }} 74 <div class="mt-8"> 75 {{ template "repo/pulls/fragments/pullStack" . }} ··· 78 {{ end }} 79 80 {{ define "repoAfter" }} 81 + <div id="pull-close"></div> 82 + <div id="pull-reopen"></div> 83 + {{ end }} 84 + 85 + {{ define "diffLayout" }} 86 + {{ $diff := index . 0 }} 87 + {{ $opts := index . 1 }} 88 + {{ $root := index . 2 }} 89 + 90 + <div class="flex col-span-full"> 91 + <!-- left panel --> 92 + <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 93 + {{ template "repo/fragments/diffChangedFiles" $diff }} 94 + </div> 95 + 96 + <!-- main content --> 97 + <div class="flex-1 min-w-0 sticky top-12 pb-12"> 98 + {{ template "diffFiles" (list $diff $opts) }} 99 + </div> 100 + 101 + <!-- right panel --> 102 + {{ template "subsPanel" $ }} 103 + </div> 104 + {{ end }} 105 + 106 + {{ define "subsPanel" }} 107 + {{ $root := index . 2 }} 108 + <!-- Backdrop overlay - only visible on mobile when open --> 109 + <div class=" 110 + fixed inset-0 bg-black/50 z-50 md:hidden opacity-0 111 + pointer-events-none transition-opacity duration-300 112 + has-[~#subs_details[open]]:opacity-100 has-[~#subs_details[open]]:pointer-events-auto"> 113 + </div> 114 + <!-- right panel - bottom sheet on mobile, side panel on desktop --> 115 + <div id="subs" class="fixed bottom-0 left-0 right-0 z-50 w-full md:static md:z-auto md:max-h-screen md:sticky md:top-12 overflow-hidden"> 116 + <details open id="bottomSheet" class="group rounded-t-2xl md:rounded-none bg-white dark:bg-gray-800 md:bg-transparent drop-shadow-lg md:drop-shadow-none border-t border-gray-200 dark:border-gray-700"> 117 + <summary class="flex gap-4 items-center justify-between rounded-t-2xl md:rounded-none cursor-pointer list-none p-4 md:h-12 bg-white dark:bg-gray-800 drop-shadow-sm border-b border-x border-gray-200 dark:border-gray-700"> 118 + <h2 class="">Review Panel</h2> 119 + <div class="block md:hidden"> 120 + <span class="inline group-open:hidden">{{ i "chevron-up" "size-4" }}</span> 121 + <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 122 + </div> 123 + </summary> 124 + <div class="max-h-[60vh] md:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col-reverse gap-4 overflow-y-auto dark:bg-gray-900"> 125 + {{ template "submissions" $root }} 126 </div> 127 + </details> 128 + </div> 129 + {{ end }} 130 + 131 + {{ define "subsCheckbox" }} 132 + <input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/> 133 + {{ end }} 134 + 135 + {{ define "subsToggle" }} 136 + <style> 137 + /* Mobile: full width */ 138 + #subsToggle:checked ~ div div#subs { 139 + width: 100%; 140 + margin-left: 0; 141 + } 142 143 + /* Desktop: 25vw with left margin */ 144 + @media (min-width: 768px) { 145 + #subsToggle:checked ~ div div#subs { 146 + width: 25vw; 147 + margin-left: 1rem; 148 + } 149 + /* Unchecked state */ 150 + #subsToggle:not(:checked) ~ div div#subs { 151 + width: 0; 152 + display: none; 153 + margin-left: 0; 154 + } 155 + } 156 + </style> 157 + <label for="subsToggle" class="hidden md:inline-flex items-center justify-center rounded cursor-pointer p-2"> 158 + {{ i "message-square-more" "size-4" }} 159 + </label> 160 {{ end }} 161 162 + 163 {{ define "submissions" }} 164 {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 165 + {{ range $ridx, $item := reverse .Pull.Submissions }} 166 + {{ $idx := sub $lastIdx $ridx }} 167 + {{ template "submission" (list $item $idx $lastIdx $) }} 168 + {{ end }} 169 + {{ end }} 170 171 + {{ define "submission" }} 172 + {{ $item := index . 0 }} 173 + {{ $idx := index . 1 }} 174 + {{ $lastIdx := index . 2 }} 175 + {{ $root := index . 3 }} 176 + <div class="rounded border border-gray-200 dark:border-gray-700 w-full shadow-sm bg-gray-50 dark:bg-gray-800/50"> 177 + {{ template "submissionHeader" $ }} 178 + {{ template "submissionComments" $ }} 179 180 + {{ if eq $lastIdx $item.RoundNumber }} 181 + {{ block "mergeStatus" $root }} {{ end }} 182 + {{ block "resubmitStatus" $root }} {{ end }} 183 + {{ end }} 184 185 + {{ if $root.LoggedInUser }} 186 + {{ template "repo/pulls/fragments/pullActions" 187 + (dict 188 + "LoggedInUser" $root.LoggedInUser 189 + "Pull" $root.Pull 190 + "RepoInfo" $root.RepoInfo 191 + "RoundNumber" $item.RoundNumber 192 + "MergeCheck" $root.MergeCheck 193 + "ResubmitCheck" $root.ResubmitCheck 194 + "BranchDeleteStatus" $root.BranchDeleteStatus 195 + "Stack" $root.Stack) }} 196 + {{ else }} 197 + {{ template "loginPrompt" $ }} 198 + {{ end }} 199 + </div> 200 + {{ end }} 201 202 + {{ define "submissionHeader" }} 203 + {{ $item := index . 0 }} 204 + {{ $lastIdx := index . 2 }} 205 + {{ $root := index . 3 }} 206 + {{ $round := $item.RoundNumber }} 207 + <div class="rounded px-6 pr-4 py-4 bg-white dark:bg-gray-800 flex gap-2 sticky top-0 z-20 border-b border-gray-200 dark:border-gray-700"> 208 + <!-- left column: just profile picture --> 209 + <div class="flex-shrink-0"> 210 + <img 211 + src="{{ tinyAvatar $root.Pull.OwnerDid }}" 212 + alt="" 213 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 214 + /> 215 + </div> 216 + <!-- right column --> 217 + <div class="flex-1 min-w-0 flex flex-col gap-1"> 218 + {{ template "submissionInfo" $ }} 219 + {{ template "submissionCommits" $ }} 220 + {{ template "submissionPipeline" $ }} 221 + {{ if eq $lastIdx $round }} 222 + {{ block "mergeCheck" $root }} {{ end }} 223 + {{ end }} 224 + </div> 225 + </div> 226 + {{ end }} 227 228 + {{ define "submissionInfo" }} 229 + {{ $item := index . 0 }} 230 + {{ $idx := index . 1 }} 231 + {{ $root := index . 3 }} 232 + {{ $round := $item.RoundNumber }} 233 + <div class="flex gap-2 items-center justify-between mb-1"> 234 + <span class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 235 + {{ resolve $root.Pull.OwnerDid }} submitted v{{ $round }} 236 + <span class="select-none before:content-['\00B7']"></span> 237 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ $round }}"> 238 + {{ template "repo/fragments/shortTimeAgo" $item.Created }} 239 + </a> 240 + </span> 241 + {{ if ne $idx 0 }} 242 + <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 243 + hx-boost="true" 244 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}/interdiff"> 245 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 246 + interdiff 247 + </a> 248 + {{ end }} 249 + </div> 250 + {{ end }} 251 252 + {{ define "submissionCommits" }} 253 + {{ $item := index . 0 }} 254 + {{ $root := index . 3 }} 255 + {{ $round := $item.RoundNumber }} 256 + {{ $patches := $item.AsFormatPatch }} 257 + <details class="group/commit"> 258 + <summary class="list-none cursor-pointer flex items-center gap-2"> 259 + <span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span> 260 + {{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }} 261 + <div class="text-sm text-gray-500 dark:text-gray-400"> 262 + <span class="group-open/commit:hidden inline">expand</span> 263 + <span class="hidden group-open/commit:inline">collapse</span> 264 + </div> 265 + </summary> 266 + {{ range $patches }} 267 + {{ template "submissionCommit" (list . $item $root) }} 268 + {{ end }} 269 + </details> 270 + {{ end }} 271 272 + {{ define "submissionCommit" }} 273 + {{ $patch := index . 0 }} 274 + {{ $item := index . 1 }} 275 + {{ $root := index . 2 }} 276 + {{ $round := $item.RoundNumber }} 277 + {{ with $patch }} 278 + <div id="commit-{{.SHA}}" class="py-1 relative w-full md:max-w-3/5 md:w-fit flex flex-col text-gray-600 dark:text-gray-300"> 279 + <div class="flex items-baseline gap-2"> 280 + <div> 281 + <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 282 + {{ $fullRepo := "" }} 283 + {{ if and $root.Pull.IsForkBased $root.Pull.PullSource.Repo }} 284 + {{ $fullRepo = printf "%s/%s" $root.Pull.OwnerDid $root.Pull.PullSource.Repo.Name }} 285 + {{ else if $root.Pull.IsBranchBased }} 286 + {{ $fullRepo = $root.RepoInfo.FullName }} 287 {{ end }} 288 289 + <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 290 + {{ if $fullRepo }} 291 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a> 292 {{ else }} 293 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 294 + {{ end }} 295 + </div> 296 + 297 + <div> 298 + <span>{{ .Title | description }}</span> 299 + {{ if gt (len .Body) 0 }} 300 + <button 301 + class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 302 + hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 303 + > 304 + {{ i "ellipsis" "w-3 h-3" }} 305 + </button> 306 + {{ end }} 307 + {{ if gt (len .Body) 0 }} 308 + <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2">{{ nl2br .Body }}</p> 309 {{ end }} 310 </div> 311 + </div> 312 + </div> 313 + {{ end }} 314 + {{ end }} 315 + 316 + {{ define "mergeCheck" }} 317 + {{ $isOpen := .Pull.State.IsOpen }} 318 + {{ if and $isOpen .MergeCheck .MergeCheck.Error }} 319 + <div class="flex items-center gap-2"> 320 + {{ i "triangle-alert" "w-4 h-4 text-red-600 dark:text-red-500" }} 321 + {{ .MergeCheck.Error }} 322 + </div> 323 + {{ else if and $isOpen .MergeCheck .MergeCheck.IsConflicted }} 324 + <details class="group/conflict"> 325 + <summary class="flex items-center justify-between cursor-pointer list-none"> 326 + <div class="flex items-center gap-2 "> 327 + {{ i "triangle-alert" "text-red-600 dark:text-red-500 w-4 h-4" }} 328 + <span class="font-medium">merge conflicts detected</span> 329 + <div class="text-sm text-gray-500 dark:text-gray-400"> 330 + <span class="group-open/conflict:hidden inline">expand</span> 331 + <span class="hidden group-open/conflict:inline">collapse</span> 332 + </div> 333 + </div> 334 + </summary> 335 + {{ if gt (len .MergeCheck.Conflicts) 0 }} 336 + <ul class="space-y-1 mt-2"> 337 + {{ range .MergeCheck.Conflicts }} 338 + {{ if .Filename }} 339 + <li class="flex items-center"> 340 + {{ i "file-warning" "inline-flex w-4 h-4 mr-1.5 text-red-600 dark:text-red-500 flex-shrink-0" }} 341 + <span class="font-mono" style="word-break: keep-all; overflow-wrap: break-word;">{{ .Filename }}</span> 342 + </li> 343 + {{ else if .Reason }} 344 + <li class="flex items-center"> 345 + {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 346 + <span>{{.Reason}}</span> 347 + </li> 348 + {{ end }} 349 + {{ end }} 350 + </ul> 351 + {{ end }} 352 </details> 353 + {{ else if and $isOpen .MergeCheck }} 354 + <div class="flex items-center gap-2"> 355 + {{ i "check" "w-4 h-4 text-green-600 dark:text-green-500" }} 356 + <span>no conflicts, ready to merge</span> 357 + </div> 358 {{ end }} 359 {{ end }} 360 361 {{ define "mergeStatus" }} 362 {{ if .Pull.State.IsClosed }} 363 + <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative"> 364 <div class="flex items-center gap-2 text-black dark:text-white"> 365 {{ i "ban" "w-4 h-4" }} 366 <span class="font-medium">closed without merging</span ··· 368 </div> 369 </div> 370 {{ else if .Pull.State.IsMerged }} 371 + <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative"> 372 <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 373 {{ i "git-merge" "w-4 h-4" }} 374 <span class="font-medium">pull request successfully merged</span ··· 376 </div> 377 </div> 378 {{ else if .Pull.State.IsDeleted }} 379 + <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative"> 380 <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 381 {{ i "git-pull-request-closed" "w-4 h-4" }} 382 <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 383 </div> 384 </div> 385 {{ end }} 386 {{ end }} 387 388 {{ define "resubmitStatus" }} 389 {{ if .ResubmitCheck.Yes }} 390 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative"> 391 <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 392 {{ i "triangle-alert" "w-4 h-4" }} 393 <span class="font-medium">this branch has been updated, consider resubmitting</span> ··· 396 {{ end }} 397 {{ end }} 398 399 + {{ define "submissionPipeline" }} 400 + {{ $item := index . 0 }} 401 + {{ $root := index . 3 }} 402 + {{ $pipeline := index $root.Pipelines $item.SourceRev }} 403 {{ with $pipeline }} 404 {{ $id := .Id }} 405 {{ if .Statuses }} 406 + <details class="group/pipeline"> 407 + <summary class="cursor-pointer list-none flex items-center gap-2"> 408 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }} 409 + <div class="text-sm text-gray-500 dark:text-gray-400"> 410 + <span class="group-open/pipeline:hidden inline">expand</span> 411 + <span class="hidden group-open/pipeline:inline">collapse</span> 412 + </div> 413 + </summary> 414 + <div class="my-2 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 415 + {{ range $name, $all := .Statuses }} 416 + <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 417 + <div 418 + class="flex gap-2 items-center justify-between p-2"> 419 + {{ $lastStatus := $all.Latest }} 420 + {{ $kind := $lastStatus.Status.String }} 421 422 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 423 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 424 + {{ $name }} 425 + </div> 426 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 427 + <span class="font-bold">{{ $kind }}</span> 428 + {{ if .TimeTaken }} 429 + {{ template "repo/fragments/duration" .TimeTaken }} 430 + {{ else }} 431 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 432 + {{ end }} 433 + </div> 434 + </div> 435 + </a> 436 + {{ end }} 437 </div> 438 + </details> 439 {{ end }} 440 {{ end }} 441 {{ end }} 442 + 443 + {{ define "submissionComments" }} 444 + {{ $item := index . 0 }} 445 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 446 + {{ range $item.Comments }} 447 + {{ template "submissionComment" . }} 448 + {{ end }} 449 + </div> 450 + {{ end }} 451 + 452 + {{ define "submissionComment" }} 453 + <div id="comment-{{.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 454 + <!-- left column: profile picture --> 455 + <div class="flex-shrink-0"> 456 + <img 457 + src="{{ tinyAvatar .OwnerDid }}" 458 + alt="" 459 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 460 + /> 461 + </div> 462 + <!-- right column: name and body in two rows --> 463 + <div class="flex-1 min-w-0"> 464 + <!-- Row 1: Author and timestamp --> 465 + <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 466 + <span>{{ resolve .OwnerDid }}</span> 467 + <span class="before:content-['ยท']"></span> 468 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"> 469 + {{ template "repo/fragments/time" .Created }} 470 + </a> 471 + </div> 472 + <!-- Row 2: Body text --> 473 + <div class="prose dark:prose-invert mt-1"> 474 + {{ .Body | markdown }} 475 + </div> 476 + </div> 477 + </div> 478 + {{ end }} 479 + 480 + {{ define "loginPrompt" }} 481 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center"> 482 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 483 + sign up 484 + </a> 485 + <span class="text-gray-500 dark:text-gray-400">or</span> 486 + <a href="/login" class="underline">login</a> 487 + to add to the discussion 488 + </div> 489 + {{ end }}
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 136 {{ $pipeline := index $.Pipelines .LatestSha }} 137 {{ if and $pipeline $pipeline.Id }} 138 <span class="before:content-['ยท']"></span> 139 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 140 {{ end }} 141 142 {{ $state := .Labels }}
··· 136 {{ $pipeline := index $.Pipelines .LatestSha }} 137 {{ if and $pipeline $pipeline.Id }} 138 <span class="before:content-['ยท']"></span> 139 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 140 {{ end }} 141 142 {{ $state := .Labels }}
+13 -4
appview/pulls/pulls.go
··· 232 defs[l.AtUri().String()] = &l 233 } 234 235 - s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 236 LoggedInUser: user, 237 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 238 Pull: pull, ··· 243 MergeCheck: mergeCheckResponse, 244 ResubmitCheck: resubmitResult, 245 Pipelines: m, 246 247 OrderedReactionKinds: models.OrderedReactionKinds, 248 Reactions: reactionMap, 249 UserReacted: userReactions, 250 251 LabelDefs: defs, 252 - }) 253 } 254 255 func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { ··· 519 520 interdiff := patchutil.Interdiff(previousPatch, currentPatch) 521 522 - s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 523 LoggedInUser: s.oauth.GetUser(r), 524 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 525 Pull: pull, 526 Round: roundIdInt, 527 Interdiff: interdiff, 528 DiffOpts: diffOpts, 529 - }) 530 } 531 532 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
··· 232 defs[l.AtUri().String()] = &l 233 } 234 235 + patch := pull.LatestSubmission().CombinedPatch() 236 + diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 237 + var diffOpts types.DiffOpts 238 + if d := r.URL.Query().Get("diff"); d == "split" { 239 + diffOpts.Split = true 240 + } 241 + 242 + log.Println(s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 243 LoggedInUser: user, 244 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 245 Pull: pull, ··· 250 MergeCheck: mergeCheckResponse, 251 ResubmitCheck: resubmitResult, 252 Pipelines: m, 253 + Diff: &diff, 254 + DiffOpts: diffOpts, 255 256 OrderedReactionKinds: models.OrderedReactionKinds, 257 Reactions: reactionMap, 258 UserReacted: userReactions, 259 260 LabelDefs: defs, 261 + })) 262 } 263 264 func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { ··· 528 529 interdiff := patchutil.Interdiff(previousPatch, currentPatch) 530 531 + fmt.Println(s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 532 LoggedInUser: s.oauth.GetUser(r), 533 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 534 Pull: pull, 535 Round: roundIdInt, 536 Interdiff: interdiff, 537 DiffOpts: diffOpts, 538 + })) 539 } 540 541 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
-12
appview/service/issue/errors.go
··· 1 - package issue 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrUnAuthenticated = errors.New("user session missing") 7 - ErrForbidden = errors.New("unauthorized operation") 8 - ErrDatabaseFail = errors.New("db op fail") 9 - ErrPDSFail = errors.New("pds op fail") 10 - ErrIndexerFail = errors.New("indexer fail") 11 - ErrValidationFail = errors.New("issue validation fail") 12 - )
···
-275
appview/service/issue/issue.go
··· 1 - package issue 2 - 3 - import ( 4 - "context" 5 - "log/slog" 6 - "time" 7 - 8 - "github.com/bluesky-social/indigo/api/atproto" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "tangled.org/core/api/tangled" 12 - "tangled.org/core/appview/config" 13 - "tangled.org/core/appview/db" 14 - issues_indexer "tangled.org/core/appview/indexer/issues" 15 - "tangled.org/core/appview/mentions" 16 - "tangled.org/core/appview/models" 17 - "tangled.org/core/appview/notify" 18 - "tangled.org/core/appview/session" 19 - "tangled.org/core/appview/validator" 20 - "tangled.org/core/idresolver" 21 - "tangled.org/core/orm" 22 - "tangled.org/core/rbac" 23 - "tangled.org/core/tid" 24 - ) 25 - 26 - type Service struct { 27 - config *config.Config 28 - db *db.DB 29 - enforcer *rbac.Enforcer 30 - indexer *issues_indexer.Indexer 31 - logger *slog.Logger 32 - notifier notify.Notifier 33 - idResolver *idresolver.Resolver 34 - refResolver *mentions.Resolver 35 - validator *validator.Validator 36 - } 37 - 38 - func NewService( 39 - logger *slog.Logger, 40 - config *config.Config, 41 - db *db.DB, 42 - enforcer *rbac.Enforcer, 43 - notifier notify.Notifier, 44 - idResolver *idresolver.Resolver, 45 - refResolver *mentions.Resolver, 46 - indexer *issues_indexer.Indexer, 47 - validator *validator.Validator, 48 - ) Service { 49 - return Service{ 50 - config, 51 - db, 52 - enforcer, 53 - indexer, 54 - logger, 55 - notifier, 56 - idResolver, 57 - refResolver, 58 - validator, 59 - } 60 - } 61 - 62 - func (s *Service) NewIssue(ctx context.Context, repo *models.Repo, title, body string) (*models.Issue, error) { 63 - l := s.logger.With("method", "NewIssue") 64 - sess := session.FromContext(ctx) 65 - if sess == nil { 66 - l.Error("user session is missing in context") 67 - return nil, ErrForbidden 68 - } 69 - authorDid := sess.Data.AccountDID 70 - l = l.With("did", authorDid) 71 - 72 - mentions, references := s.refResolver.Resolve(ctx, body) 73 - 74 - issue := models.Issue{ 75 - RepoAt: repo.RepoAt(), 76 - Rkey: tid.TID(), 77 - Title: title, 78 - Body: body, 79 - Open: true, 80 - Did: authorDid.String(), 81 - Created: time.Now(), 82 - Mentions: mentions, 83 - References: references, 84 - Repo: repo, 85 - } 86 - 87 - if err := s.validator.ValidateIssue(&issue); err != nil { 88 - l.Error("validation error", "err", err) 89 - return nil, ErrValidationFail 90 - } 91 - 92 - tx, err := s.db.BeginTx(ctx, nil) 93 - if err != nil { 94 - l.Error("db.BeginTx failed", "err", err) 95 - return nil, ErrDatabaseFail 96 - } 97 - defer tx.Rollback() 98 - 99 - if err := db.PutIssue(tx, &issue); err != nil { 100 - l.Error("db.PutIssue failed", "err", err) 101 - return nil, ErrDatabaseFail 102 - } 103 - 104 - atpclient := sess.APIClient() 105 - record := issue.AsRecord() 106 - _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 107 - Repo: authorDid.String(), 108 - Collection: tangled.RepoIssueNSID, 109 - Rkey: issue.Rkey, 110 - Record: &lexutil.LexiconTypeDecoder{ 111 - Val: &record, 112 - }, 113 - }) 114 - if err != nil { 115 - l.Error("atproto.RepoPutRecord failed", "err", err) 116 - return nil, ErrPDSFail 117 - } 118 - if err = tx.Commit(); err != nil { 119 - l.Error("tx.Commit failed", "err", err) 120 - return nil, ErrDatabaseFail 121 - } 122 - 123 - s.notifier.NewIssue(ctx, &issue, mentions) 124 - return &issue, nil 125 - } 126 - 127 - func (s *Service) GetIssues(ctx context.Context, repo *models.Repo, searchOpts models.IssueSearchOptions) ([]models.Issue, error) { 128 - l := s.logger.With("method", "GetIssues") 129 - 130 - var issues []models.Issue 131 - var err error 132 - if searchOpts.Keyword != "" { 133 - res, err := s.indexer.Search(ctx, searchOpts) 134 - if err != nil { 135 - l.Error("failed to search for issues", "err", err) 136 - return nil, ErrIndexerFail 137 - } 138 - l.Debug("searched issues with indexer", "count", len(res.Hits)) 139 - issues, err = db.GetIssues(s.db, orm.FilterIn("id", res.Hits)) 140 - if err != nil { 141 - l.Error("failed to get issues", "err", err) 142 - return nil, ErrDatabaseFail 143 - } 144 - } else { 145 - openInt := 0 146 - if searchOpts.IsOpen { 147 - openInt = 1 148 - } 149 - issues, err = db.GetIssuesPaginated( 150 - s.db, 151 - searchOpts.Page, 152 - orm.FilterEq("repo_at", repo.RepoAt()), 153 - orm.FilterEq("open", openInt), 154 - ) 155 - if err != nil { 156 - l.Error("failed to get issues", "err", err) 157 - return nil, ErrDatabaseFail 158 - } 159 - } 160 - 161 - return issues, nil 162 - } 163 - 164 - func (s *Service) EditIssue(ctx context.Context, issue *models.Issue) error { 165 - l := s.logger.With("method", "EditIssue") 166 - sess := session.FromContext(ctx) 167 - if sess == nil { 168 - l.Error("user session is missing in context") 169 - return ErrForbidden 170 - } 171 - sessDid := sess.Data.AccountDID 172 - l = l.With("did", sessDid) 173 - 174 - mentions, references := s.refResolver.Resolve(ctx, issue.Body) 175 - issue.Mentions = mentions 176 - issue.References = references 177 - 178 - if sessDid != syntax.DID(issue.Did) { 179 - l.Error("only author can edit the issue") 180 - return ErrForbidden 181 - } 182 - 183 - if err := s.validator.ValidateIssue(issue); err != nil { 184 - l.Error("validation error", "err", err) 185 - return ErrValidationFail 186 - } 187 - 188 - tx, err := s.db.BeginTx(ctx, nil) 189 - if err != nil { 190 - l.Error("db.BeginTx failed", "err", err) 191 - return ErrDatabaseFail 192 - } 193 - defer tx.Rollback() 194 - 195 - if err := db.PutIssue(tx, issue); err != nil { 196 - l.Error("db.PutIssue failed", "err", err) 197 - return ErrDatabaseFail 198 - } 199 - 200 - atpclient := sess.APIClient() 201 - record := issue.AsRecord() 202 - 203 - ex, err := atproto.RepoGetRecord(ctx, atpclient, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 204 - if err != nil { 205 - l.Error("atproto.RepoGetRecord failed", "err", err) 206 - return ErrPDSFail 207 - } 208 - _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 209 - Collection: tangled.RepoIssueNSID, 210 - SwapRecord: ex.Cid, 211 - Record: &lexutil.LexiconTypeDecoder{ 212 - Val: &record, 213 - }, 214 - }) 215 - if err != nil { 216 - l.Error("atproto.RepoPutRecord failed", "err", err) 217 - return ErrPDSFail 218 - } 219 - 220 - if err = tx.Commit(); err != nil { 221 - l.Error("tx.Commit failed", "err", err) 222 - return ErrDatabaseFail 223 - } 224 - 225 - // TODO: notify PutIssue 226 - 227 - return nil 228 - } 229 - 230 - func (s *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error { 231 - l := s.logger.With("method", "DeleteIssue") 232 - sess := session.FromContext(ctx) 233 - if sess == nil { 234 - l.Error("user session is missing in context") 235 - return ErrForbidden 236 - } 237 - sessDid := sess.Data.AccountDID 238 - l = l.With("did", sessDid) 239 - 240 - if sessDid != syntax.DID(issue.Did) { 241 - l.Error("only author can edit the issue") 242 - return ErrForbidden 243 - } 244 - 245 - tx, err := s.db.BeginTx(ctx, nil) 246 - if err != nil { 247 - l.Error("db.BeginTx failed", "err", err) 248 - return ErrDatabaseFail 249 - } 250 - defer tx.Rollback() 251 - 252 - if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 253 - l.Error("db.DeleteIssues failed", "err", err) 254 - return ErrDatabaseFail 255 - } 256 - 257 - atpclient := sess.APIClient() 258 - _, err = atproto.RepoDeleteRecord(ctx, atpclient, &atproto.RepoDeleteRecord_Input{ 259 - Collection: tangled.RepoIssueNSID, 260 - Repo: issue.Did, 261 - Rkey: issue.Rkey, 262 - }) 263 - if err != nil { 264 - l.Error("atproto.RepoDeleteRecord failed", "err", err) 265 - return ErrPDSFail 266 - } 267 - 268 - if err := tx.Commit(); err != nil { 269 - l.Error("tx.Commit failed", "err", err) 270 - return ErrDatabaseFail 271 - } 272 - 273 - s.notifier.DeleteIssue(ctx, issue) 274 - return nil 275 - }
···
-84
appview/service/issue/state.go
··· 1 - package issue 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/bluesky-social/indigo/atproto/syntax" 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/pages/repoinfo" 10 - "tangled.org/core/appview/session" 11 - "tangled.org/core/orm" 12 - ) 13 - 14 - func (s *Service) CloseIssue(ctx context.Context, issue *models.Issue) error { 15 - l := s.logger.With("method", "CloseIssue") 16 - sess := session.FromContext(ctx) 17 - if sess == nil { 18 - l.Error("user session is missing in context") 19 - return ErrUnAuthenticated 20 - } 21 - sessDid := sess.Data.AccountDID 22 - l = l.With("did", sessDid) 23 - 24 - // TODO: make this more granular 25 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(sessDid.String(), issue.Repo.Knot, issue.Repo.DidSlashRepo())} 26 - isRepoOwner := roles.IsOwner() 27 - isCollaborator := roles.IsCollaborator() 28 - isIssueOwner := sessDid == syntax.DID(issue.Did) 29 - if !(isRepoOwner || isCollaborator || isIssueOwner) { 30 - l.Error("user is not authorized") 31 - return ErrForbidden 32 - } 33 - 34 - err := db.CloseIssues( 35 - s.db, 36 - orm.FilterEq("id", issue.Id), 37 - ) 38 - if err != nil { 39 - l.Error("db.CloseIssues failed", "err", err) 40 - return ErrDatabaseFail 41 - } 42 - 43 - // change the issue state (this will pass down to the notifiers) 44 - issue.Open = false 45 - 46 - s.notifier.NewIssueState(ctx, sessDid, issue) 47 - return nil 48 - } 49 - 50 - func (s *Service) ReopenIssue(ctx context.Context, issue *models.Issue) error { 51 - l := s.logger.With("method", "ReopenIssue") 52 - sess := session.FromContext(ctx) 53 - if sess == nil { 54 - l.Error("user session is missing in context") 55 - return ErrUnAuthenticated 56 - } 57 - sessDid := sess.Data.AccountDID 58 - l = l.With("did", sessDid) 59 - 60 - // TODO: make this more granular 61 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(sessDid.String(), issue.Repo.Knot, issue.Repo.DidSlashRepo())} 62 - isRepoOwner := roles.IsOwner() 63 - isCollaborator := roles.IsCollaborator() 64 - isIssueOwner := sessDid == syntax.DID(issue.Did) 65 - if !(isRepoOwner || isCollaborator || isIssueOwner) { 66 - l.Error("user is not authorized") 67 - return ErrForbidden 68 - } 69 - 70 - err := db.ReopenIssues( 71 - s.db, 72 - orm.FilterEq("id", issue.Id), 73 - ) 74 - if err != nil { 75 - l.Error("db.ReopenIssues failed", "err", err) 76 - return ErrDatabaseFail 77 - } 78 - 79 - // change the issue state (this will pass down to the notifiers) 80 - issue.Open = true 81 - 82 - s.notifier.NewIssueState(ctx, sessDid, issue) 83 - return nil 84 - }
···
-11
appview/service/repo/errors.go
··· 1 - package repo 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrUnAuthenticated = errors.New("user session missing") 7 - ErrForbidden = errors.New("unauthorized operation") 8 - ErrDatabaseFail = errors.New("db op fail") 9 - ErrPDSFail = errors.New("pds op fail") 10 - ErrValidationFail = errors.New("repo validation fail") 11 - )
···
-89
appview/service/repo/repo.go
··· 1 - package repo 2 - 3 - import ( 4 - "context" 5 - "log/slog" 6 - "time" 7 - 8 - "github.com/bluesky-social/indigo/api/atproto" 9 - "tangled.org/core/api/tangled" 10 - "tangled.org/core/appview/config" 11 - "tangled.org/core/appview/db" 12 - "tangled.org/core/appview/models" 13 - "tangled.org/core/appview/session" 14 - "tangled.org/core/rbac" 15 - "tangled.org/core/tid" 16 - ) 17 - 18 - type Service struct { 19 - logger *slog.Logger 20 - config *config.Config 21 - db *db.DB 22 - enforcer *rbac.Enforcer 23 - } 24 - 25 - func NewService( 26 - logger *slog.Logger, 27 - config *config.Config, 28 - db *db.DB, 29 - enforcer *rbac.Enforcer, 30 - ) Service { 31 - return Service{ 32 - logger, 33 - config, 34 - db, 35 - enforcer, 36 - } 37 - } 38 - 39 - // NewRepo creates a repository 40 - // It expects atproto session to be passed in `ctx` 41 - func (s *Service) NewRepo(ctx context.Context, name, description, knot string) (*models.Repo, error) { 42 - l := s.logger.With("method", "NewRepo") 43 - sess := session.FromContext(ctx) 44 - if sess == nil { 45 - l.Error("user session is missing in context") 46 - return nil, ErrForbidden 47 - } 48 - 49 - ownerDid := sess.Data.AccountDID 50 - l = l.With("did", ownerDid) 51 - 52 - repo := models.Repo{ 53 - Did: ownerDid.String(), 54 - Name: name, 55 - Knot: knot, 56 - Rkey: tid.TID(), 57 - Description: description, 58 - Created: time.Now(), 59 - Labels: s.config.Label.DefaultLabelDefs, 60 - } 61 - l = l.With("aturi", repo.RepoAt()) 62 - 63 - tx, err := s.db.BeginTx(ctx, nil) 64 - if err != nil { 65 - l.Error("db.BeginTx failed", "err", err) 66 - return nil, ErrDatabaseFail 67 - } 68 - defer tx.Rollback() 69 - 70 - if err = db.AddRepo(tx, &repo); err != nil { 71 - l.Error("db.AddRepo failed", "err", err) 72 - return nil, ErrDatabaseFail 73 - } 74 - 75 - atpclient := sess.APIClient() 76 - _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 77 - Collection: tangled.RepoNSID, 78 - Repo: repo.Did, 79 - }) 80 - if err != nil { 81 - l.Error("atproto.RepoPutRecord failed", "err", err) 82 - return nil, ErrPDSFail 83 - } 84 - l.Info("wrote to PDS") 85 - 86 - // knotclient, err := s.oauth.ServiceClient( 87 - // ) 88 - panic("unimplemented") 89 - }
···
-90
appview/service/repo/repoinfo.go
··· 1 - package repo 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/bluesky-social/indigo/atproto/identity" 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/oauth" 10 - "tangled.org/core/appview/pages/repoinfo" 11 - ) 12 - 13 - // GetRepoInfo converts given `Repo` to `RepoInfo` object. 14 - // The `user` can be nil. 15 - // NOTE: RepoInfo is bad design and should be removed in future. 16 - // avoid using this method if you can. 17 - func (s *Service) GetRepoInfo( 18 - ctx context.Context, 19 - ownerId *identity.Identity, 20 - baseRepo *models.Repo, 21 - currentDir, ref string, 22 - user *oauth.User, 23 - ) (*repoinfo.RepoInfo, error) { 24 - var ( 25 - repoAt = baseRepo.RepoAt() 26 - isStarred = false 27 - roles = repoinfo.RolesInRepo{} 28 - ) 29 - if user != nil { 30 - isStarred = db.GetStarStatus(s.db, user.Did, repoAt) 31 - roles.Roles = s.enforcer.GetPermissionsInRepo(user.Did, baseRepo.Knot, baseRepo.DidSlashRepo()) 32 - } 33 - 34 - stats := baseRepo.RepoStats 35 - if stats == nil { 36 - starCount, err := db.GetStarCount(s.db, repoAt) 37 - if err != nil { 38 - return nil, err 39 - } 40 - issueCount, err := db.GetIssueCount(s.db, repoAt) 41 - if err != nil { 42 - return nil, err 43 - } 44 - pullCount, err := db.GetPullCount(s.db, repoAt) 45 - if err != nil { 46 - return nil, err 47 - } 48 - stats = &models.RepoStats{ 49 - StarCount: starCount, 50 - IssueCount: issueCount, 51 - PullCount: pullCount, 52 - } 53 - } 54 - 55 - var sourceRepo *models.Repo 56 - var err error 57 - if baseRepo.Source != "" { 58 - sourceRepo, err = db.GetRepoByAtUri(s.db, baseRepo.Source) 59 - if err != nil { 60 - return nil, err 61 - } 62 - } 63 - 64 - repoInfo := &repoinfo.RepoInfo{ 65 - // ok this is basically a models.Repo 66 - OwnerDid: baseRepo.Did, 67 - OwnerHandle: ownerId.Handle.String(), // TODO: shouldn't use 68 - Name: baseRepo.Name, 69 - Rkey: baseRepo.Rkey, 70 - Description: baseRepo.Description, 71 - Website: baseRepo.Website, 72 - Topics: baseRepo.Topics, 73 - Knot: baseRepo.Knot, 74 - Spindle: baseRepo.Spindle, 75 - Stats: *stats, 76 - 77 - // fork repo upstream 78 - Source: sourceRepo, 79 - 80 - // repo path (context) 81 - CurrentDir: currentDir, 82 - Ref: ref, 83 - 84 - // info related to the session 85 - IsStarred: isStarred, 86 - Roles: roles, 87 - } 88 - 89 - return repoInfo, nil 90 - }
···
-29
appview/session/context.go
··· 1 - package session 2 - 3 - import ( 4 - "context" 5 - 6 - toauth "tangled.org/core/appview/oauth" 7 - ) 8 - 9 - type ctxKey struct{} 10 - 11 - func IntoContext(ctx context.Context, sess Session) context.Context { 12 - return context.WithValue(ctx, ctxKey{}, &sess) 13 - } 14 - 15 - func FromContext(ctx context.Context) *Session { 16 - sess, ok := ctx.Value(ctxKey{}).(*Session) 17 - if !ok { 18 - return nil 19 - } 20 - return sess 21 - } 22 - 23 - func UserFromContext(ctx context.Context) *toauth.User { 24 - sess := FromContext(ctx) 25 - if sess == nil { 26 - return nil 27 - } 28 - return sess.User() 29 - }
···
-24
appview/session/session.go
··· 1 - package session 2 - 3 - import ( 4 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 5 - toauth "tangled.org/core/appview/oauth" 6 - ) 7 - 8 - // Session is a lightweight wrapper over indigo-oauth ClientSession 9 - type Session struct { 10 - *oauth.ClientSession 11 - } 12 - 13 - func New(atSess *oauth.ClientSession) Session { 14 - return Session{ 15 - atSess, 16 - } 17 - } 18 - 19 - func (s *Session) User() *toauth.User { 20 - return &toauth.User{ 21 - Did: string(s.Data.AccountDID), 22 - Pds: s.Data.HostURL, 23 - } 24 - }
···
-66
appview/state/legacy_bridge.go
··· 1 - package state 2 - 3 - import ( 4 - "log/slog" 5 - 6 - "tangled.org/core/appview/config" 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/indexer" 9 - "tangled.org/core/appview/issues" 10 - "tangled.org/core/appview/mentions" 11 - "tangled.org/core/appview/middleware" 12 - "tangled.org/core/appview/notify" 13 - "tangled.org/core/appview/oauth" 14 - "tangled.org/core/appview/pages" 15 - "tangled.org/core/appview/validator" 16 - "tangled.org/core/idresolver" 17 - "tangled.org/core/log" 18 - "tangled.org/core/rbac" 19 - ) 20 - 21 - // Expose exposes private fields in `State`. This is used to bridge between 22 - // legacy web routers and new architecture 23 - func (s *State) Expose() ( 24 - *config.Config, 25 - *db.DB, 26 - *rbac.Enforcer, 27 - *idresolver.Resolver, 28 - *mentions.Resolver, 29 - *indexer.Indexer, 30 - *slog.Logger, 31 - notify.Notifier, 32 - *oauth.OAuth, 33 - *pages.Pages, 34 - *validator.Validator, 35 - ) { 36 - return s.config, s.db, s.enforcer, s.idResolver, s.mentionsResolver, s.indexer, s.logger, s.notifier, s.oauth, s.pages, s.validator 37 - } 38 - 39 - func (s *State) ExposeIssue() *issues.Issues { 40 - return issues.New( 41 - s.oauth, 42 - s.repoResolver, 43 - s.enforcer, 44 - s.pages, 45 - s.idResolver, 46 - s.mentionsResolver, 47 - s.db, 48 - s.config, 49 - s.notifier, 50 - s.validator, 51 - s.indexer.Issues, 52 - log.SubLogger(s.logger, "issues"), 53 - ) 54 - } 55 - 56 - func (s *State) Middleware() *middleware.Middleware { 57 - mw := middleware.New( 58 - s.oauth, 59 - s.db, 60 - s.enforcer, 61 - s.repoResolver, 62 - s.idResolver, 63 - s.pages, 64 - ) 65 - return &mw 66 - }
···
-34
appview/web/handler/oauth.go
··· 1 - package handler 2 - 3 - import ( 4 - "encoding/json" 5 - "net/http" 6 - 7 - "tangled.org/core/appview/oauth" 8 - ) 9 - 10 - func OauthClientMetadata(o *oauth.OAuth) http.HandlerFunc { 11 - return func(w http.ResponseWriter, r *http.Request) { 12 - doc := o.ClientApp.Config.ClientMetadata() 13 - doc.JWKSURI = &o.JwksUri 14 - doc.ClientName = &o.ClientName 15 - doc.ClientURI = &o.ClientUri 16 - 17 - w.Header().Set("Content-Type", "application/json") 18 - if err := json.NewEncoder(w).Encode(doc); err != nil { 19 - http.Error(w, err.Error(), http.StatusInternalServerError) 20 - return 21 - } 22 - } 23 - } 24 - 25 - func OauthJwks(o *oauth.OAuth) http.HandlerFunc { 26 - return func(w http.ResponseWriter, r *http.Request) { 27 - w.Header().Set("Content-Type", "application/json") 28 - body := o.ClientApp.Config.PublicJWKS() 29 - if err := json.NewEncoder(w).Encode(body); err != nil { 30 - http.Error(w, err.Error(), http.StatusInternalServerError) 31 - return 32 - } 33 - } 34 - }
···
-379
appview/web/handler/user_repo_issues.go
··· 1 - package handler 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "net/http" 7 - 8 - "tangled.org/core/api/tangled" 9 - "tangled.org/core/appview/db" 10 - "tangled.org/core/appview/models" 11 - "tangled.org/core/appview/pages" 12 - "tangled.org/core/appview/pagination" 13 - "tangled.org/core/appview/reporesolver" 14 - isvc "tangled.org/core/appview/service/issue" 15 - rsvc "tangled.org/core/appview/service/repo" 16 - "tangled.org/core/appview/session" 17 - "tangled.org/core/appview/web/request" 18 - "tangled.org/core/log" 19 - "tangled.org/core/orm" 20 - ) 21 - 22 - func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 23 - return func(w http.ResponseWriter, r *http.Request) { 24 - ctx := r.Context() 25 - l := log.FromContext(ctx).With("handler", "RepoIssues") 26 - repo, ok := request.RepoFromContext(ctx) 27 - if !ok { 28 - l.Error("malformed request") 29 - p.Error503(w) 30 - return 31 - } 32 - repoOwnerId, ok := request.OwnerFromContext(ctx) 33 - if !ok { 34 - l.Error("malformed request") 35 - p.Error503(w) 36 - return 37 - } 38 - 39 - query := r.URL.Query() 40 - searchOpts := models.IssueSearchOptions{ 41 - RepoAt: repo.RepoAt().String(), 42 - Keyword: query.Get("q"), 43 - IsOpen: query.Get("state") != "closed", 44 - Page: pagination.FromContext(ctx), 45 - } 46 - 47 - issues, err := is.GetIssues(ctx, repo, searchOpts) 48 - if err != nil { 49 - l.Error("failed to get issues") 50 - p.Error503(w) 51 - return 52 - } 53 - 54 - // render page 55 - err = func() error { 56 - user := session.UserFromContext(ctx) 57 - repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, repo, "", "", user) 58 - if err != nil { 59 - return err 60 - } 61 - labelDefs, err := db.GetLabelDefinitions( 62 - d, 63 - orm.FilterIn("at_uri", repo.Labels), 64 - orm.FilterContains("scope", tangled.RepoIssueNSID), 65 - ) 66 - if err != nil { 67 - return err 68 - } 69 - defs := make(map[string]*models.LabelDefinition) 70 - for _, l := range labelDefs { 71 - defs[l.AtUri().String()] = &l 72 - } 73 - return p.RepoIssues(w, pages.RepoIssuesParams{ 74 - LoggedInUser: user, 75 - RepoInfo: *repoinfo, 76 - 77 - Issues: issues, 78 - LabelDefs: defs, 79 - FilteringByOpen: searchOpts.IsOpen, 80 - FilterQuery: searchOpts.Keyword, 81 - Page: searchOpts.Page, 82 - }) 83 - }() 84 - if err != nil { 85 - l.Error("failed to render", "err", err) 86 - p.Error503(w) 87 - return 88 - } 89 - } 90 - } 91 - 92 - func Issue(s isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 93 - return func(w http.ResponseWriter, r *http.Request) { 94 - ctx := r.Context() 95 - l := log.FromContext(ctx).With("handler", "Issue") 96 - issue, ok := request.IssueFromContext(ctx) 97 - if !ok { 98 - l.Error("malformed request, failed to get issue") 99 - p.Error503(w) 100 - return 101 - } 102 - repoOwnerId, ok := request.OwnerFromContext(ctx) 103 - if !ok { 104 - l.Error("malformed request") 105 - p.Error503(w) 106 - return 107 - } 108 - 109 - // render 110 - err := func() error { 111 - user := session.UserFromContext(ctx) 112 - repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, issue.Repo, "", "", user) 113 - if err != nil { 114 - l.Error("failed to load repo", "err", err) 115 - return err 116 - } 117 - 118 - reactionMap, err := db.GetReactionMap(d, 20, issue.AtUri()) 119 - if err != nil { 120 - l.Error("failed to get issue reactions", "err", err) 121 - return err 122 - } 123 - 124 - userReactions := map[models.ReactionKind]bool{} 125 - if user != nil { 126 - userReactions = db.GetReactionStatusMap(d, user.Did, issue.AtUri()) 127 - } 128 - 129 - backlinks, err := db.GetBacklinks(d, issue.AtUri()) 130 - if err != nil { 131 - l.Error("failed to fetch backlinks", "err", err) 132 - return err 133 - } 134 - 135 - labelDefs, err := db.GetLabelDefinitions( 136 - d, 137 - orm.FilterIn("at_uri", issue.Repo.Labels), 138 - orm.FilterContains("scope", tangled.RepoIssueNSID), 139 - ) 140 - if err != nil { 141 - l.Error("failed to fetch label defs", "err", err) 142 - return err 143 - } 144 - 145 - defs := make(map[string]*models.LabelDefinition) 146 - for _, l := range labelDefs { 147 - defs[l.AtUri().String()] = &l 148 - } 149 - 150 - return p.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 151 - LoggedInUser: user, 152 - RepoInfo: *repoinfo, 153 - Issue: issue, 154 - CommentList: issue.CommentList(), 155 - Backlinks: backlinks, 156 - OrderedReactionKinds: models.OrderedReactionKinds, 157 - Reactions: reactionMap, 158 - UserReacted: userReactions, 159 - LabelDefs: defs, 160 - }) 161 - }() 162 - if err != nil { 163 - l.Error("failed to render", "err", err) 164 - p.Error503(w) 165 - return 166 - } 167 - } 168 - } 169 - 170 - func NewIssue(rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 171 - return func(w http.ResponseWriter, r *http.Request) { 172 - ctx := r.Context() 173 - l := log.FromContext(ctx).With("handler", "NewIssue") 174 - 175 - // render 176 - err := func() error { 177 - user := session.UserFromContext(ctx) 178 - repo, ok := request.RepoFromContext(ctx) 179 - if !ok { 180 - return fmt.Errorf("malformed request") 181 - } 182 - repoOwnerId, ok := request.OwnerFromContext(ctx) 183 - if !ok { 184 - return fmt.Errorf("malformed request") 185 - } 186 - repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, repo, "", "", user) 187 - if err != nil { 188 - return err 189 - } 190 - return p.RepoNewIssue(w, pages.RepoNewIssueParams{ 191 - LoggedInUser: user, 192 - RepoInfo: *repoinfo, 193 - }) 194 - }() 195 - if err != nil { 196 - l.Error("failed to render", "err", err) 197 - p.Error503(w) 198 - return 199 - } 200 - } 201 - } 202 - 203 - func NewIssuePost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 204 - noticeId := "issues" 205 - return func(w http.ResponseWriter, r *http.Request) { 206 - ctx := r.Context() 207 - l := log.FromContext(ctx).With("handler", "NewIssuePost") 208 - repo, ok := request.RepoFromContext(ctx) 209 - if !ok { 210 - l.Error("malformed request, failed to get repo") 211 - // TODO: 503 error with more detailed messages 212 - p.Error503(w) 213 - return 214 - } 215 - var ( 216 - title = r.FormValue("title") 217 - body = r.FormValue("body") 218 - ) 219 - 220 - _, err := is.NewIssue(ctx, repo, title, body) 221 - if err != nil { 222 - if errors.Is(err, isvc.ErrDatabaseFail) { 223 - p.Notice(w, noticeId, "Failed to create issue.") 224 - } else if errors.Is(err, isvc.ErrPDSFail) { 225 - p.Notice(w, noticeId, "Failed to create issue.") 226 - } else { 227 - p.Notice(w, noticeId, "Failed to create issue.") 228 - } 229 - return 230 - } 231 - p.HxLocation(w, "/") 232 - } 233 - } 234 - 235 - func IssueEdit(is isvc.Service, rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 236 - return func(w http.ResponseWriter, r *http.Request) { 237 - ctx := r.Context() 238 - l := log.FromContext(ctx).With("handler", "IssueEdit") 239 - issue, ok := request.IssueFromContext(ctx) 240 - if !ok { 241 - l.Error("malformed request, failed to get issue") 242 - p.Error503(w) 243 - return 244 - } 245 - repoOwnerId, ok := request.OwnerFromContext(ctx) 246 - if !ok { 247 - l.Error("malformed request") 248 - p.Error503(w) 249 - return 250 - } 251 - 252 - // render 253 - err := func() error { 254 - user := session.UserFromContext(ctx) 255 - repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, issue.Repo, "", "", user) 256 - if err != nil { 257 - return err 258 - } 259 - return p.EditIssueFragment(w, pages.EditIssueParams{ 260 - LoggedInUser: user, 261 - RepoInfo: *repoinfo, 262 - 263 - Issue: issue, 264 - }) 265 - }() 266 - if err != nil { 267 - l.Error("failed to render", "err", err) 268 - p.Error503(w) 269 - return 270 - } 271 - } 272 - } 273 - 274 - func IssueEditPost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 275 - noticeId := "issues" 276 - return func(w http.ResponseWriter, r *http.Request) { 277 - ctx := r.Context() 278 - l := log.FromContext(ctx).With("handler", "IssueEdit") 279 - issue, ok := request.IssueFromContext(ctx) 280 - if !ok { 281 - l.Error("malformed request, failed to get issue") 282 - p.Error503(w) 283 - return 284 - } 285 - 286 - newIssue := *issue 287 - newIssue.Title = r.FormValue("title") 288 - newIssue.Body = r.FormValue("body") 289 - 290 - err := is.EditIssue(ctx, &newIssue) 291 - if err != nil { 292 - if errors.Is(err, isvc.ErrDatabaseFail) { 293 - p.Notice(w, noticeId, "Failed to edit issue.") 294 - } else if errors.Is(err, isvc.ErrPDSFail) { 295 - p.Notice(w, noticeId, "Failed to edit issue.") 296 - } else { 297 - p.Notice(w, noticeId, "Failed to edit issue.") 298 - } 299 - return 300 - } 301 - 302 - p.HxRefresh(w) 303 - } 304 - } 305 - 306 - func CloseIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 307 - noticeId := "issue-action" 308 - return func(w http.ResponseWriter, r *http.Request) { 309 - ctx := r.Context() 310 - l := log.FromContext(ctx).With("handler", "CloseIssue") 311 - issue, ok := request.IssueFromContext(ctx) 312 - if !ok { 313 - l.Error("malformed request, failed to get issue") 314 - p.Error503(w) 315 - return 316 - } 317 - 318 - err := is.CloseIssue(ctx, issue) 319 - if err != nil { 320 - if errors.Is(err, isvc.ErrForbidden) { 321 - http.Error(w, "forbidden", http.StatusUnauthorized) 322 - } else { 323 - p.Notice(w, noticeId, "Failed to close issue. Try again later.") 324 - } 325 - return 326 - } 327 - 328 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 329 - p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 330 - } 331 - } 332 - 333 - func ReopenIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 334 - noticeId := "issue-action" 335 - return func(w http.ResponseWriter, r *http.Request) { 336 - ctx := r.Context() 337 - l := log.FromContext(ctx).With("handler", "ReopenIssue") 338 - issue, ok := request.IssueFromContext(ctx) 339 - if !ok { 340 - l.Error("malformed request, failed to get issue") 341 - p.Error503(w) 342 - return 343 - } 344 - 345 - err := is.ReopenIssue(ctx, issue) 346 - if err != nil { 347 - if errors.Is(err, isvc.ErrForbidden) { 348 - http.Error(w, "forbidden", http.StatusUnauthorized) 349 - } else { 350 - p.Notice(w, noticeId, "Failed to reopen issue. Try again later.") 351 - } 352 - return 353 - } 354 - 355 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 356 - p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 357 - } 358 - } 359 - 360 - func IssueDelete(s isvc.Service, p *pages.Pages) http.HandlerFunc { 361 - noticeId := "issue-actions-error" 362 - return func(w http.ResponseWriter, r *http.Request) { 363 - ctx := r.Context() 364 - l := log.FromContext(ctx).With("handler", "IssueDelete") 365 - issue, ok := request.IssueFromContext(ctx) 366 - if !ok { 367 - l.Error("failed to get issue") 368 - // TODO: 503 error with more detailed messages 369 - p.Error503(w) 370 - return 371 - } 372 - err := s.DeleteIssue(ctx, issue) 373 - if err != nil { 374 - p.Notice(w, noticeId, "failed to delete issue") 375 - return 376 - } 377 - p.HxLocation(w, "/") 378 - } 379 - }
···
-67
appview/web/middleware/auth.go
··· 1 - package middleware 2 - 3 - import ( 4 - "fmt" 5 - "net/http" 6 - "net/url" 7 - 8 - "tangled.org/core/appview/oauth" 9 - "tangled.org/core/appview/session" 10 - "tangled.org/core/log" 11 - ) 12 - 13 - // WithSession resumes atp session from cookie, ensure it's not malformed and 14 - // pass the session through context 15 - func WithSession(o *oauth.OAuth) middlewareFunc { 16 - return func(next http.Handler) http.Handler { 17 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 - atSess, err := o.ResumeSession(r) 19 - if err != nil { 20 - next.ServeHTTP(w, r) 21 - return 22 - } 23 - 24 - sess := session.New(atSess) 25 - 26 - ctx := session.IntoContext(r.Context(), sess) 27 - next.ServeHTTP(w, r.WithContext(ctx)) 28 - }) 29 - } 30 - } 31 - 32 - // AuthMiddleware ensures the request is authorized and redirect to login page 33 - // when unauthorized 34 - func AuthMiddleware() middlewareFunc { 35 - return func(next http.Handler) http.Handler { 36 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 - ctx := r.Context() 38 - l := log.FromContext(ctx) 39 - 40 - returnURL := "/" 41 - if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 42 - returnURL = u.RequestURI() 43 - } 44 - 45 - loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 46 - 47 - redirectFunc := func(w http.ResponseWriter, r *http.Request) { 48 - http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 49 - } 50 - if r.Header.Get("HX-Request") == "true" { 51 - redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 52 - w.Header().Set("HX-Redirect", loginURL) 53 - w.WriteHeader(http.StatusOK) 54 - } 55 - } 56 - 57 - sess := session.FromContext(ctx) 58 - if sess == nil { 59 - l.Debug("no session, redirecting...") 60 - redirectFunc(w, r) 61 - return 62 - } 63 - 64 - next.ServeHTTP(w, r) 65 - }) 66 - } 67 - }
···
-27
appview/web/middleware/ensuredidorhandle.go
··· 1 - package middleware 2 - 3 - import ( 4 - "net/http" 5 - 6 - "github.com/go-chi/chi/v5" 7 - "tangled.org/core/appview/pages" 8 - "tangled.org/core/appview/state/userutil" 9 - ) 10 - 11 - // EnsureDidOrHandle ensures the "user" url param is valid did/handle format. 12 - // If not, respond with 404 13 - func EnsureDidOrHandle(p *pages.Pages) middlewareFunc { 14 - return func(next http.Handler) http.Handler { 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - user := chi.URLParam(r, "user") 17 - 18 - // if using a DID or handle, just continue as per usual 19 - if userutil.IsDid(user) || userutil.IsHandle(user) { 20 - next.ServeHTTP(w, r) 21 - return 22 - } 23 - 24 - p.Error404(w) 25 - }) 26 - } 27 - }
···
-18
appview/web/middleware/log.go
··· 1 - package middleware 2 - 3 - import ( 4 - "log/slog" 5 - "net/http" 6 - 7 - "tangled.org/core/log" 8 - ) 9 - 10 - func WithLogger(l *slog.Logger) middlewareFunc { 11 - return func(next http.Handler) http.Handler { 12 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 - // NOTE: can add some metadata here 14 - ctx := log.IntoContext(r.Context(), l) 15 - next.ServeHTTP(w, r.WithContext(ctx)) 16 - }) 17 - } 18 - }
···
-7
appview/web/middleware/middleware.go
··· 1 - package middleware 2 - 3 - import ( 4 - "net/http" 5 - ) 6 - 7 - type middlewareFunc func(http.Handler) http.Handler
···
-50
appview/web/middleware/normalize.go
··· 1 - package middleware 2 - 3 - import ( 4 - "net/http" 5 - "strings" 6 - 7 - "github.com/go-chi/chi/v5" 8 - "tangled.org/core/appview/state/userutil" 9 - ) 10 - 11 - func Normalize() middlewareFunc { 12 - return func(next http.Handler) http.Handler { 13 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 - pat := chi.URLParam(r, "*") 15 - pathParts := strings.SplitN(pat, "/", 2) 16 - if len(pathParts) == 0 { 17 - next.ServeHTTP(w, r) 18 - return 19 - } 20 - 21 - firstPart := pathParts[0] 22 - 23 - // if using a flattened DID (like you would in go modules), unflatten 24 - if userutil.IsFlattenedDid(firstPart) { 25 - unflattenedDid := userutil.UnflattenDid(firstPart) 26 - redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 27 - 28 - redirectURL := *r.URL 29 - redirectURL.Path = "/" + redirectPath 30 - 31 - http.Redirect(w, r, redirectURL.String(), http.StatusFound) 32 - return 33 - } 34 - 35 - // if using a handle with @, rewrite to work without @ 36 - if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 37 - redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 38 - 39 - redirectURL := *r.URL 40 - redirectURL.Path = "/" + redirectPath 41 - 42 - http.Redirect(w, r, redirectURL.String(), http.StatusFound) 43 - return 44 - } 45 - 46 - next.ServeHTTP(w, r) 47 - return 48 - }) 49 - } 50 - }
···
-38
appview/web/middleware/paginate.go
··· 1 - package middleware 2 - 3 - import ( 4 - "log" 5 - "net/http" 6 - "strconv" 7 - 8 - "tangled.org/core/appview/pagination" 9 - ) 10 - 11 - func Paginate(next http.Handler) http.Handler { 12 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 - page := pagination.FirstPage() 14 - 15 - offsetVal := r.URL.Query().Get("offset") 16 - if offsetVal != "" { 17 - offset, err := strconv.Atoi(offsetVal) 18 - if err != nil { 19 - log.Println("invalid offset") 20 - } else { 21 - page.Offset = offset 22 - } 23 - } 24 - 25 - limitVal := r.URL.Query().Get("limit") 26 - if limitVal != "" { 27 - limit, err := strconv.Atoi(limitVal) 28 - if err != nil { 29 - log.Println("invalid limit") 30 - } else { 31 - page.Limit = limit 32 - } 33 - } 34 - 35 - ctx := pagination.IntoContext(r.Context(), page) 36 - next.ServeHTTP(w, r.WithContext(ctx)) 37 - }) 38 - }
···
-121
appview/web/middleware/resolve.go
··· 1 - package middleware 2 - 3 - import ( 4 - "context" 5 - "net/http" 6 - "strconv" 7 - "strings" 8 - 9 - "github.com/go-chi/chi/v5" 10 - "tangled.org/core/appview/db" 11 - "tangled.org/core/appview/pages" 12 - "tangled.org/core/appview/web/request" 13 - "tangled.org/core/idresolver" 14 - "tangled.org/core/log" 15 - "tangled.org/core/orm" 16 - ) 17 - 18 - func ResolveIdent( 19 - idResolver *idresolver.Resolver, 20 - pages *pages.Pages, 21 - ) middlewareFunc { 22 - return func(next http.Handler) http.Handler { 23 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 - ctx := r.Context() 25 - l := log.FromContext(ctx) 26 - didOrHandle := chi.URLParam(r, "user") 27 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 28 - 29 - id, err := idResolver.ResolveIdent(ctx, didOrHandle) 30 - if err != nil { 31 - // invalid did or handle 32 - l.Warn("failed to resolve did/handle", "handle", didOrHandle, "err", err) 33 - pages.Error404(w) 34 - return 35 - } 36 - 37 - ctx = request.WithOwner(ctx, id) 38 - // TODO: reomove this later 39 - ctx = context.WithValue(ctx, "resolvedId", *id) 40 - 41 - next.ServeHTTP(w, r.WithContext(ctx)) 42 - }) 43 - } 44 - } 45 - 46 - func ResolveRepo( 47 - e *db.DB, 48 - pages *pages.Pages, 49 - ) middlewareFunc { 50 - return func(next http.Handler) http.Handler { 51 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 - ctx := r.Context() 53 - l := log.FromContext(ctx) 54 - repoName := chi.URLParam(r, "repo") 55 - repoOwner, ok := request.OwnerFromContext(ctx) 56 - if !ok { 57 - l.Error("malformed middleware") 58 - w.WriteHeader(http.StatusInternalServerError) 59 - return 60 - } 61 - 62 - repo, err := db.GetRepo( 63 - e, 64 - orm.FilterEq("did", repoOwner.DID.String()), 65 - orm.FilterEq("name", repoName), 66 - ) 67 - if err != nil { 68 - l.Warn("failed to resolve repo", "err", err) 69 - pages.ErrorKnot404(w) 70 - return 71 - } 72 - 73 - // TODO: pass owner id into repository object 74 - 75 - ctx = request.WithRepo(ctx, repo) 76 - // TODO: reomove this later 77 - ctx = context.WithValue(ctx, "repo", repo) 78 - 79 - next.ServeHTTP(w, r.WithContext(ctx)) 80 - }) 81 - } 82 - } 83 - 84 - func ResolveIssue( 85 - e *db.DB, 86 - pages *pages.Pages, 87 - ) middlewareFunc { 88 - return func(next http.Handler) http.Handler { 89 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 - ctx := r.Context() 91 - l := log.FromContext(ctx) 92 - issueIdStr := chi.URLParam(r, "issue") 93 - issueId, err := strconv.Atoi(issueIdStr) 94 - if err != nil { 95 - l.Warn("failed to fully resolve issue ID", "err", err) 96 - pages.Error404(w) 97 - return 98 - } 99 - repo, ok := request.RepoFromContext(ctx) 100 - if !ok { 101 - l.Error("malformed middleware") 102 - w.WriteHeader(http.StatusInternalServerError) 103 - return 104 - } 105 - 106 - issue, err := db.GetIssue(e, repo.RepoAt(), issueId) 107 - if err != nil { 108 - l.Warn("failed to resolve issue", "err", err) 109 - pages.ErrorKnot404(w) 110 - return 111 - } 112 - issue.Repo = repo 113 - 114 - ctx = request.WithIssue(ctx, issue) 115 - // TODO: reomove this later 116 - ctx = context.WithValue(ctx, "issue", issue) 117 - 118 - next.ServeHTTP(w, r.WithContext(ctx)) 119 - }) 120 - } 121 - }
···
-53
appview/web/readme.md
··· 1 - # appview/web 2 - 3 - ## package structure 4 - 5 - ``` 6 - web/ 7 - |- routes.go 8 - |- handler/ 9 - | |- xrpc/ 10 - |- middleware/ 11 - |- request/ 12 - ``` 13 - 14 - - `web/routes.go` : all possible routes defined in single file 15 - - `web/handler` : general http handlers 16 - - `web/handler/xrpc` : xrpc handlers 17 - - `web/middleware` : all middlwares 18 - - `web/request` : define methods to insert/fetch values from request context. shared between middlewares and handlers. 19 - 20 - ### file name convention on `web/handler` 21 - 22 - - Follow the absolute uri path of the handlers (replace `/` to `_`.) 23 - - Trailing path segments can be omitted. 24 - - Avoid conflicts between prefix and names. 25 - - e.g. using both `user_repo_pulls.go` and `user_repo_pulls_rounds.go` (with `user_repo_pulls_` prefix) 26 - 27 - ### handler-generators instead of raw handler function 28 - 29 - instead of: 30 - ```go 31 - type Handler struct { 32 - is isvc.Service 33 - rs rsvc.Service 34 - } 35 - func (h *Handler) RepoIssues(w http.ResponseWriter, r *http.Request) { 36 - // ... 37 - } 38 - ``` 39 - 40 - prefer: 41 - ```go 42 - func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 43 - return func(w http.ResponseWriter, r *http.Request) { 44 - // ... 45 - } 46 - } 47 - ``` 48 - 49 - Pass dependencies to each handler-generators and avoid creating structs with shared dependencies unless it serves somedomain-specific roles like `service/issue.Service`. Same rule applies to middlewares too. 50 - 51 - This pattern is inspired by [the grafana blog post](https://grafana.com/blog/how-i-write-http-services-in-go-after-13-years/#maker-funcs-return-the-handler). 52 - 53 - Function name can be anything as long as it is clear.
···
-41
appview/web/request/context.go
··· 1 - package request 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/bluesky-social/indigo/atproto/identity" 7 - "tangled.org/core/appview/models" 8 - ) 9 - 10 - type ( 11 - ctxKeyOwner struct{} 12 - ctxKeyRepo struct{} 13 - ctxKeyIssue struct{} 14 - ) 15 - 16 - func WithOwner(ctx context.Context, owner *identity.Identity) context.Context { 17 - return context.WithValue(ctx, ctxKeyOwner{}, owner) 18 - } 19 - 20 - func OwnerFromContext(ctx context.Context) (*identity.Identity, bool) { 21 - owner, ok := ctx.Value(ctxKeyOwner{}).(*identity.Identity) 22 - return owner, ok 23 - } 24 - 25 - func WithRepo(ctx context.Context, repo *models.Repo) context.Context { 26 - return context.WithValue(ctx, ctxKeyRepo{}, repo) 27 - } 28 - 29 - func RepoFromContext(ctx context.Context) (*models.Repo, bool) { 30 - repo, ok := ctx.Value(ctxKeyRepo{}).(*models.Repo) 31 - return repo, ok 32 - } 33 - 34 - func WithIssue(ctx context.Context, issue *models.Issue) context.Context { 35 - return context.WithValue(ctx, ctxKeyIssue{}, issue) 36 - } 37 - 38 - func IssueFromContext(ctx context.Context) (*models.Issue, bool) { 39 - issue, ok := ctx.Value(ctxKeyIssue{}).(*models.Issue) 40 - return issue, ok 41 - }
···
-205
appview/web/routes.go
··· 1 - package web 2 - 3 - import ( 4 - "log/slog" 5 - "net/http" 6 - 7 - "github.com/go-chi/chi/v5" 8 - "tangled.org/core/appview/config" 9 - "tangled.org/core/appview/db" 10 - "tangled.org/core/appview/indexer" 11 - "tangled.org/core/appview/mentions" 12 - "tangled.org/core/appview/notify" 13 - "tangled.org/core/appview/oauth" 14 - "tangled.org/core/appview/pages" 15 - isvc "tangled.org/core/appview/service/issue" 16 - rsvc "tangled.org/core/appview/service/repo" 17 - "tangled.org/core/appview/state" 18 - "tangled.org/core/appview/validator" 19 - "tangled.org/core/appview/web/handler" 20 - "tangled.org/core/appview/web/middleware" 21 - "tangled.org/core/idresolver" 22 - "tangled.org/core/rbac" 23 - ) 24 - 25 - // RouterFromState creates a web router from `state.State`. This exist to 26 - // bridge between legacy web routers under `State` and new architecture 27 - func RouterFromState(s *state.State) http.Handler { 28 - config, db, enforcer, idResolver, refResolver, indexer, logger, notifier, oauth, pages, validator := s.Expose() 29 - 30 - return Router( 31 - logger, 32 - config, 33 - db, 34 - enforcer, 35 - idResolver, 36 - refResolver, 37 - indexer, 38 - notifier, 39 - oauth, 40 - pages, 41 - validator, 42 - s, 43 - ) 44 - } 45 - 46 - func Router( 47 - // NOTE: put base dependencies (db, idResolver, oauth etc) 48 - logger *slog.Logger, 49 - config *config.Config, 50 - db *db.DB, 51 - enforcer *rbac.Enforcer, 52 - idResolver *idresolver.Resolver, 53 - mentionsResolver *mentions.Resolver, 54 - indexer *indexer.Indexer, 55 - notifier notify.Notifier, 56 - oauth *oauth.OAuth, 57 - pages *pages.Pages, 58 - validator *validator.Validator, 59 - // to use legacy web handlers. will be removed later 60 - s *state.State, 61 - ) http.Handler { 62 - repo := rsvc.NewService( 63 - logger, 64 - config, 65 - db, 66 - enforcer, 67 - ) 68 - issue := isvc.NewService( 69 - logger, 70 - config, 71 - db, 72 - enforcer, 73 - notifier, 74 - idResolver, 75 - mentionsResolver, 76 - indexer.Issues, 77 - validator, 78 - ) 79 - 80 - i := s.ExposeIssue() 81 - 82 - r := chi.NewRouter() 83 - 84 - mw := s.Middleware() 85 - auth := middleware.AuthMiddleware() 86 - 87 - r.Use(middleware.WithLogger(logger)) 88 - r.Use(middleware.WithSession(oauth)) 89 - 90 - r.Use(middleware.Normalize()) 91 - 92 - r.Get("/pwa-manifest.json", s.WebAppManifest) 93 - r.Get("/robots.txt", s.RobotsTxt) 94 - 95 - r.Handle("/static/*", pages.Static()) 96 - 97 - r.Get("/", s.HomeOrTimeline) 98 - r.Get("/timeline", s.Timeline) 99 - r.Get("/upgradeBanner", s.UpgradeBanner) 100 - 101 - r.Get("/terms", s.TermsOfService) 102 - r.Get("/privacy", s.PrivacyPolicy) 103 - r.Get("/brand", s.Brand) 104 - // special-case handler for serving tangled.org/core 105 - r.Get("/core", s.Core()) 106 - 107 - r.Get("/login", s.Login) 108 - r.Post("/login", s.Login) 109 - r.Post("/logout", s.Logout) 110 - 111 - r.Get("/goodfirstissues", s.GoodFirstIssues) 112 - 113 - r.With(auth).Get("/repo/new", s.NewRepo) 114 - r.With(auth).Post("/repo/new", s.NewRepo) 115 - 116 - r.With(auth).Post("/follow", s.Follow) 117 - r.With(auth).Delete("/follow", s.Follow) 118 - 119 - r.With(auth).Post("/star", s.Star) 120 - r.With(auth).Delete("/star", s.Star) 121 - 122 - r.With(auth).Post("/react", s.React) 123 - r.With(auth).Delete("/react", s.React) 124 - 125 - r.With(auth).Get("/profile/edit-bio", s.EditBioFragment) 126 - r.With(auth).Get("/profile/edit-pins", s.EditPinsFragment) 127 - r.With(auth).Post("/profile/bio", s.UpdateProfileBio) 128 - r.With(auth).Post("/profile/pins", s.UpdateProfilePins) 129 - 130 - r.Mount("/settings", s.SettingsRouter()) 131 - r.Mount("/strings", s.StringsRouter(mw)) 132 - r.Mount("/settings/knots", s.KnotsRouter()) 133 - r.Mount("/settings/spindles", s.SpindlesRouter()) 134 - r.Mount("/notifications", s.NotificationsRouter(mw)) 135 - 136 - r.Mount("/signup", s.SignupRouter()) 137 - r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(oauth)) 138 - r.Get("/oauth/jwks.json", handler.OauthJwks(oauth)) 139 - r.Get("/oauth/callback", oauth.Callback) 140 - 141 - // special-case handler. should replace with xrpc later 142 - r.Get("/keys/{user}", s.Keys) 143 - 144 - r.HandleFunc("/@*", func(w http.ResponseWriter, r *http.Request) { 145 - http.Redirect(w, r, "/"+chi.URLParam(r, "*"), http.StatusFound) 146 - }) 147 - 148 - r.Route("/{user}", func(r chi.Router) { 149 - r.Use(middleware.EnsureDidOrHandle(pages)) 150 - r.Use(middleware.ResolveIdent(idResolver, pages)) 151 - 152 - r.Get("/", s.Profile) 153 - r.Get("/feed.atom", s.AtomFeedPage) 154 - 155 - r.Route("/{repo}", func(r chi.Router) { 156 - r.Use(middleware.ResolveRepo(db, pages)) 157 - 158 - r.Mount("/", s.RepoRouter(mw)) 159 - 160 - // /{user}/{repo}/issues/* 161 - r.With(middleware.Paginate).Get("/issues", handler.RepoIssues(issue, repo, pages, db)) 162 - r.With(auth).Get("/issues/new", handler.NewIssue(repo, pages)) 163 - r.With(auth).Post("/issues/new", handler.NewIssuePost(issue, pages)) 164 - r.Route("/issues/{issue}", func(r chi.Router) { 165 - r.Use(middleware.ResolveIssue(db, pages)) 166 - 167 - r.Get("/", handler.Issue(issue, repo, pages, db)) 168 - r.Get("/opengraph", i.IssueOpenGraphSummary) 169 - 170 - r.With(auth).Delete("/", handler.IssueDelete(issue, pages)) 171 - 172 - r.With(auth).Get("/edit", handler.IssueEdit(issue, repo, pages)) 173 - r.With(auth).Post("/edit", handler.IssueEditPost(issue, pages)) 174 - 175 - r.With(auth).Post("/close", handler.CloseIssue(issue, pages)) 176 - r.With(auth).Post("/reopen", handler.ReopenIssue(issue, pages)) 177 - 178 - r.With(auth).Post("/comment", i.NewIssueComment) 179 - r.With(auth).Route("/comment/{commentId}/", func(r chi.Router) { 180 - r.Get("/", i.IssueComment) 181 - r.Delete("/", i.DeleteIssueComment) 182 - r.Get("/edit", i.EditIssueComment) 183 - r.Post("/edit", i.EditIssueComment) 184 - r.Get("/reply", i.ReplyIssueComment) 185 - r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 186 - }) 187 - }) 188 - 189 - r.Mount("/pulls", s.PullsRouter(mw)) 190 - r.Mount("/pipelines", s.PipelinesRouter()) 191 - r.Mount("/labels", s.LabelsRouter()) 192 - 193 - // These routes get proxied to the knot 194 - r.Get("/info/refs", s.InfoRefs) 195 - r.Post("/git-upload-pack", s.UploadPack) 196 - r.Post("/git-receive-pack", s.ReceivePack) 197 - }) 198 - }) 199 - 200 - r.NotFound(func(w http.ResponseWriter, r *http.Request) { 201 - pages.Error404(w) 202 - }) 203 - 204 - return r 205 - }
···
+1 -2
cmd/appview/main.go
··· 7 8 "tangled.org/core/appview/config" 9 "tangled.org/core/appview/state" 10 - "tangled.org/core/appview/web" 11 tlog "tangled.org/core/log" 12 ) 13 ··· 36 37 logger.Info("starting server", "address", c.Core.ListenAddr) 38 39 - if err := http.ListenAndServe(c.Core.ListenAddr, web.RouterFromState(state)); err != nil { 40 logger.Error("failed to start appview", "err", err) 41 } 42 }
··· 7 8 "tangled.org/core/appview/config" 9 "tangled.org/core/appview/state" 10 tlog "tangled.org/core/log" 11 ) 12 ··· 35 36 logger.Info("starting server", "address", c.Core.ListenAddr) 37 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 logger.Error("failed to start appview", "err", err) 40 } 41 }
+6 -5
docs/template.html
··· 120 $endif$ 121 $endif$ 122 </header> 123 - $if(abstract)$ 124 - <article class="prose dark:prose-invert max-w-none"> 125 - $abstract$ 126 - </article> 127 - $endif$ 128 $endif$ 129 130 <article class="prose dark:prose-invert max-w-none">
··· 120 $endif$ 121 $endif$ 122 </header> 123 + $endif$ 124 + 125 + $if(abstract)$ 126 + <article class="prose dark:prose-invert max-w-none"> 127 + $abstract$ 128 + </article> 129 $endif$ 130 131 <article class="prose dark:prose-invert max-w-none">
+13
input.css
··· 124 dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 125 } 126 127 .btn-create { 128 @apply btn text-white 129 before:bg-green-600 hover:before:bg-green-700
··· 124 dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 125 } 126 127 + .btn-flat { 128 + @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 129 + bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900 130 + before:absolute before:inset-0 before:-z-10 before:block before:rounded 131 + before:border before:border-gray-200 before:bg-white 132 + before:content-[''] before:transition-all before:duration-150 before:ease-in-out 133 + hover:before:bg-gray-50 134 + dark:hover:before:bg-gray-700 135 + focus:outline-none focus-visible:before:outline focus-visible:before:outline-2 focus-visible:before:outline-gray-400 136 + disabled:cursor-not-allowed disabled:opacity-50 137 + dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 138 + } 139 + 140 .btn-create { 141 @apply btn text-white 142 before:bg-green-600 hover:before:bg-green-700
-3
nix/modules/appview.nix
··· 1 { 2 - pkgs, 3 config, 4 lib, 5 ... ··· 260 after = ["redis-appview.service" "network-online.target"]; 261 requires = ["redis-appview.service"]; 262 wants = ["network-online.target"]; 263 - 264 - path = [pkgs.diffutils]; 265 266 serviceConfig = { 267 Type = "simple";
··· 1 { 2 config, 3 lib, 4 ... ··· 259 after = ["redis-appview.service" "network-online.target"]; 260 requires = ["redis-appview.service"]; 261 wants = ["network-online.target"]; 262 263 serviceConfig = { 264 Type = "simple";
+2 -2
spindle/models/models.go
··· 53 StatusKindRunning, 54 } 55 FinishStates [4]StatusKind = [4]StatusKind{ 56 - StatusKindCancelled, 57 StatusKindFailed, 58 - StatusKindSuccess, 59 StatusKindTimeout, 60 } 61 ) 62
··· 53 StatusKindRunning, 54 } 55 FinishStates [4]StatusKind = [4]StatusKind{ 56 StatusKindFailed, 57 StatusKindTimeout, 58 + StatusKindCancelled, 59 + StatusKindSuccess, 60 } 61 ) 62
+7 -5
types/diff.go
··· 27 } 28 29 type DiffStat struct { 30 - Insertions int64 31 - Deletions int64 32 } 33 34 - func (d *Diff) Stats() DiffStat { 35 var stats DiffStat 36 for _, f := range d.TextFragments { 37 stats.Insertions += f.LinesAdded 38 stats.Deletions += f.LinesDeleted 39 } 40 return stats 41 } 42 ··· 73 } 74 75 // used by html elements as a unique ID for hrefs 76 - func (d *Diff) Id() string { 77 if d.IsDelete { 78 return d.Name.Old 79 } 80 return d.Name.New 81 } 82 83 - func (d *Diff) Split() *SplitDiff { 84 fragments := make([]SplitFragment, len(d.TextFragments)) 85 for i, fragment := range d.TextFragments { 86 leftLines, rightLines := SeparateLines(&fragment)
··· 27 } 28 29 type DiffStat struct { 30 + Insertions int64 31 + Deletions int64 32 + FilesChanged int 33 } 34 35 + func (d Diff) Stats() DiffStat { 36 var stats DiffStat 37 for _, f := range d.TextFragments { 38 stats.Insertions += f.LinesAdded 39 stats.Deletions += f.LinesDeleted 40 } 41 + stats.FilesChanged = len(d.TextFragments) 42 return stats 43 } 44 ··· 75 } 76 77 // used by html elements as a unique ID for hrefs 78 + func (d Diff) Id() string { 79 if d.IsDelete { 80 return d.Name.Old 81 } 82 return d.Name.New 83 } 84 85 + func (d Diff) Split() *SplitDiff { 86 fragments := make([]SplitFragment, len(d.TextFragments)) 87 for i, fragment := range d.TextFragments { 88 leftLines, rightLines := SeparateLines(&fragment)