1package pages
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "embed"
7 "encoding/hex"
8 "fmt"
9 "html/template"
10 "io"
11 "io/fs"
12 "log"
13 "net/http"
14 "os"
15 "path/filepath"
16 "strings"
17
18 "tangled.sh/tangled.sh/core/appview/commitverify"
19 "tangled.sh/tangled.sh/core/appview/config"
20 "tangled.sh/tangled.sh/core/appview/db"
21 "tangled.sh/tangled.sh/core/appview/oauth"
22 "tangled.sh/tangled.sh/core/appview/pages/markup"
23 "tangled.sh/tangled.sh/core/appview/pages/repoinfo"
24 "tangled.sh/tangled.sh/core/appview/pagination"
25 "tangled.sh/tangled.sh/core/patchutil"
26 "tangled.sh/tangled.sh/core/types"
27
28 "github.com/alecthomas/chroma/v2"
29 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
30 "github.com/alecthomas/chroma/v2/lexers"
31 "github.com/alecthomas/chroma/v2/styles"
32 "github.com/bluesky-social/indigo/atproto/syntax"
33 "github.com/go-git/go-git/v5/plumbing"
34 "github.com/go-git/go-git/v5/plumbing/object"
35 "github.com/microcosm-cc/bluemonday"
36)
37
38//go:embed templates/* static
39var Files embed.FS
40
41type Pages struct {
42 t map[string]*template.Template
43 dev bool
44 embedFS embed.FS
45 templateDir string // Path to templates on disk for dev mode
46 rctx *markup.RenderContext
47}
48
49func NewPages(config *config.Config) *Pages {
50 // initialized with safe defaults, can be overriden per use
51 rctx := &markup.RenderContext{
52 IsDev: config.Core.Dev,
53 CamoUrl: config.Camo.Host,
54 CamoSecret: config.Camo.SharedSecret,
55 }
56
57 p := &Pages{
58 t: make(map[string]*template.Template),
59 dev: config.Core.Dev,
60 embedFS: Files,
61 rctx: rctx,
62 templateDir: "appview/pages",
63 }
64
65 // Initial load of all templates
66 p.loadAllTemplates()
67
68 return p
69}
70
71func (p *Pages) loadAllTemplates() {
72 templates := make(map[string]*template.Template)
73 var fragmentPaths []string
74
75 // Use embedded FS for initial loading
76 // First, collect all fragment paths
77 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
78 if err != nil {
79 return err
80 }
81 if d.IsDir() {
82 return nil
83 }
84 if !strings.HasSuffix(path, ".html") {
85 return nil
86 }
87 if !strings.Contains(path, "fragments/") {
88 return nil
89 }
90 name := strings.TrimPrefix(path, "templates/")
91 name = strings.TrimSuffix(name, ".html")
92 tmpl, err := template.New(name).
93 Funcs(funcMap()).
94 ParseFS(p.embedFS, path)
95 if err != nil {
96 log.Fatalf("setting up fragment: %v", err)
97 }
98 templates[name] = tmpl
99 fragmentPaths = append(fragmentPaths, path)
100 log.Printf("loaded fragment: %s", name)
101 return nil
102 })
103 if err != nil {
104 log.Fatalf("walking template dir for fragments: %v", err)
105 }
106
107 // Then walk through and setup the rest of the templates
108 err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
109 if err != nil {
110 return err
111 }
112 if d.IsDir() {
113 return nil
114 }
115 if !strings.HasSuffix(path, "html") {
116 return nil
117 }
118 // Skip fragments as they've already been loaded
119 if strings.Contains(path, "fragments/") {
120 return nil
121 }
122 // Skip layouts
123 if strings.Contains(path, "layouts/") {
124 return nil
125 }
126 name := strings.TrimPrefix(path, "templates/")
127 name = strings.TrimSuffix(name, ".html")
128 // Add the page template on top of the base
129 allPaths := []string{}
130 allPaths = append(allPaths, "templates/layouts/*.html")
131 allPaths = append(allPaths, fragmentPaths...)
132 allPaths = append(allPaths, path)
133 tmpl, err := template.New(name).
134 Funcs(funcMap()).
135 ParseFS(p.embedFS, allPaths...)
136 if err != nil {
137 return fmt.Errorf("setting up template: %w", err)
138 }
139 templates[name] = tmpl
140 log.Printf("loaded template: %s", name)
141 return nil
142 })
143 if err != nil {
144 log.Fatalf("walking template dir: %v", err)
145 }
146
147 log.Printf("total templates loaded: %d", len(templates))
148 p.t = templates
149}
150
151// loadTemplateFromDisk loads a template from the filesystem in dev mode
152func (p *Pages) loadTemplateFromDisk(name string) error {
153 if !p.dev {
154 return nil
155 }
156
157 log.Printf("reloading template from disk: %s", name)
158
159 // Find all fragments first
160 var fragmentPaths []string
161 err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
162 if err != nil {
163 return err
164 }
165 if d.IsDir() {
166 return nil
167 }
168 if !strings.HasSuffix(path, ".html") {
169 return nil
170 }
171 if !strings.Contains(path, "fragments/") {
172 return nil
173 }
174 fragmentPaths = append(fragmentPaths, path)
175 return nil
176 })
177 if err != nil {
178 return fmt.Errorf("walking disk template dir for fragments: %w", err)
179 }
180
181 // Find the template path on disk
182 templatePath := filepath.Join(p.templateDir, "templates", name+".html")
183 if _, err := os.Stat(templatePath); os.IsNotExist(err) {
184 return fmt.Errorf("template not found on disk: %s", name)
185 }
186
187 // Create a new template
188 tmpl := template.New(name).Funcs(funcMap())
189
190 // Parse layouts
191 layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
192 layouts, err := filepath.Glob(layoutGlob)
193 if err != nil {
194 return fmt.Errorf("finding layout templates: %w", err)
195 }
196
197 // Create paths for parsing
198 allFiles := append(layouts, fragmentPaths...)
199 allFiles = append(allFiles, templatePath)
200
201 // Parse all templates
202 tmpl, err = tmpl.ParseFiles(allFiles...)
203 if err != nil {
204 return fmt.Errorf("parsing template files: %w", err)
205 }
206
207 // Update the template in the map
208 p.t[name] = tmpl
209 log.Printf("template reloaded from disk: %s", name)
210 return nil
211}
212
213func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
214 // In dev mode, reload the template from disk before executing
215 if p.dev {
216 if err := p.loadTemplateFromDisk(templateName); err != nil {
217 log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
218 // Continue with the existing template
219 }
220 }
221
222 tmpl, exists := p.t[templateName]
223 if !exists {
224 return fmt.Errorf("template not found: %s", templateName)
225 }
226
227 if base == "" {
228 return tmpl.Execute(w, params)
229 } else {
230 return tmpl.ExecuteTemplate(w, base, params)
231 }
232}
233
234func (p *Pages) execute(name string, w io.Writer, params any) error {
235 return p.executeOrReload(name, w, "layouts/base", params)
236}
237
238func (p *Pages) executePlain(name string, w io.Writer, params any) error {
239 return p.executeOrReload(name, w, "", params)
240}
241
242func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
243 return p.executeOrReload(name, w, "layouts/repobase", params)
244}
245
246type LoginParams struct {
247}
248
249func (p *Pages) Login(w io.Writer, params LoginParams) error {
250 return p.executePlain("user/login", w, params)
251}
252
253type TimelineParams struct {
254 LoggedInUser *oauth.User
255 Timeline []db.TimelineEvent
256 DidHandleMap map[string]string
257}
258
259func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
260 return p.execute("timeline", w, params)
261}
262
263type SettingsParams struct {
264 LoggedInUser *oauth.User
265 PubKeys []db.PublicKey
266 Emails []db.Email
267}
268
269func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
270 return p.execute("settings", w, params)
271}
272
273type KnotsParams struct {
274 LoggedInUser *oauth.User
275 Registrations []db.Registration
276}
277
278func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
279 return p.execute("knots", w, params)
280}
281
282type KnotParams struct {
283 LoggedInUser *oauth.User
284 DidHandleMap map[string]string
285 Registration *db.Registration
286 Members []string
287 IsOwner bool
288}
289
290func (p *Pages) Knot(w io.Writer, params KnotParams) error {
291 return p.execute("knot", w, params)
292}
293
294type SpindlesParams struct {
295 LoggedInUser *oauth.User
296 Spindles []db.Spindle
297}
298
299func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
300 return p.execute("spindles/index", w, params)
301}
302
303type SpindleListingParams struct {
304 LoggedInUser *oauth.User
305 Spindle db.Spindle
306}
307
308func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
309 return p.execute("spindles/fragments/spindleListing", w, params)
310}
311
312type NewRepoParams struct {
313 LoggedInUser *oauth.User
314 Knots []string
315}
316
317func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
318 return p.execute("repo/new", w, params)
319}
320
321type ForkRepoParams struct {
322 LoggedInUser *oauth.User
323 Knots []string
324 RepoInfo repoinfo.RepoInfo
325}
326
327func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
328 return p.execute("repo/fork", w, params)
329}
330
331type ProfilePageParams struct {
332 LoggedInUser *oauth.User
333 Repos []db.Repo
334 CollaboratingRepos []db.Repo
335 ProfileTimeline *db.ProfileTimeline
336 Card ProfileCard
337 Punchcard db.Punchcard
338
339 DidHandleMap map[string]string
340}
341
342type ProfileCard struct {
343 UserDid string
344 UserHandle string
345 FollowStatus db.FollowStatus
346 AvatarUri string
347 Followers int
348 Following int
349
350 Profile *db.Profile
351}
352
353func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
354 return p.execute("user/profile", w, params)
355}
356
357type ReposPageParams struct {
358 LoggedInUser *oauth.User
359 Repos []db.Repo
360 Card ProfileCard
361
362 DidHandleMap map[string]string
363}
364
365func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
366 return p.execute("user/repos", w, params)
367}
368
369type FollowFragmentParams struct {
370 UserDid string
371 FollowStatus db.FollowStatus
372}
373
374func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
375 return p.executePlain("user/fragments/follow", w, params)
376}
377
378type EditBioParams struct {
379 LoggedInUser *oauth.User
380 Profile *db.Profile
381}
382
383func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
384 return p.executePlain("user/fragments/editBio", w, params)
385}
386
387type EditPinsParams struct {
388 LoggedInUser *oauth.User
389 Profile *db.Profile
390 AllRepos []PinnedRepo
391 DidHandleMap map[string]string
392}
393
394type PinnedRepo struct {
395 IsPinned bool
396 db.Repo
397}
398
399func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
400 return p.executePlain("user/fragments/editPins", w, params)
401}
402
403type RepoActionsFragmentParams struct {
404 IsStarred bool
405 RepoAt syntax.ATURI
406 Stats db.RepoStats
407}
408
409func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
410 return p.executePlain("repo/fragments/repoActions", w, params)
411}
412
413type RepoDescriptionParams struct {
414 RepoInfo repoinfo.RepoInfo
415}
416
417func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
418 return p.executePlain("repo/fragments/editRepoDescription", w, params)
419}
420
421func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
422 return p.executePlain("repo/fragments/repoDescription", w, params)
423}
424
425type RepoIndexParams struct {
426 LoggedInUser *oauth.User
427 RepoInfo repoinfo.RepoInfo
428 Active string
429 TagMap map[string][]string
430 CommitsTrunc []*object.Commit
431 TagsTrunc []*types.TagReference
432 BranchesTrunc []types.Branch
433 ForkInfo *types.ForkInfo
434 HTMLReadme template.HTML
435 Raw bool
436 EmailToDidOrHandle map[string]string
437 VerifiedCommits commitverify.VerifiedCommits
438 Languages *types.RepoLanguageResponse
439 types.RepoIndexResponse
440}
441
442func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
443 params.Active = "overview"
444 if params.IsEmpty {
445 return p.executeRepo("repo/empty", w, params)
446 }
447
448 p.rctx.RepoInfo = params.RepoInfo
449 p.rctx.RendererType = markup.RendererTypeRepoMarkdown
450
451 if params.ReadmeFileName != "" {
452 var htmlString string
453 ext := filepath.Ext(params.ReadmeFileName)
454 switch ext {
455 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
456 htmlString = p.rctx.RenderMarkdown(params.Readme)
457 params.Raw = false
458 params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
459 default:
460 htmlString = string(params.Readme)
461 params.Raw = true
462 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
463 }
464 }
465
466 return p.executeRepo("repo/index", w, params)
467}
468
469type RepoLogParams struct {
470 LoggedInUser *oauth.User
471 RepoInfo repoinfo.RepoInfo
472 TagMap map[string][]string
473 types.RepoLogResponse
474 Active string
475 EmailToDidOrHandle map[string]string
476 VerifiedCommits commitverify.VerifiedCommits
477}
478
479func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
480 params.Active = "overview"
481 return p.executeRepo("repo/log", w, params)
482}
483
484type RepoCommitParams struct {
485 LoggedInUser *oauth.User
486 RepoInfo repoinfo.RepoInfo
487 Active string
488 EmailToDidOrHandle map[string]string
489
490 // singular because it's always going to be just one
491 VerifiedCommit commitverify.VerifiedCommits
492
493 types.RepoCommitResponse
494}
495
496func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
497 params.Active = "overview"
498 return p.executeRepo("repo/commit", w, params)
499}
500
501type RepoTreeParams struct {
502 LoggedInUser *oauth.User
503 RepoInfo repoinfo.RepoInfo
504 Active string
505 BreadCrumbs [][]string
506 BaseTreeLink string
507 BaseBlobLink string
508 types.RepoTreeResponse
509}
510
511type RepoTreeStats struct {
512 NumFolders uint64
513 NumFiles uint64
514}
515
516func (r RepoTreeParams) TreeStats() RepoTreeStats {
517 numFolders, numFiles := 0, 0
518 for _, f := range r.Files {
519 if !f.IsFile {
520 numFolders += 1
521 } else if f.IsFile {
522 numFiles += 1
523 }
524 }
525
526 return RepoTreeStats{
527 NumFolders: uint64(numFolders),
528 NumFiles: uint64(numFiles),
529 }
530}
531
532func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
533 params.Active = "overview"
534 return p.execute("repo/tree", w, params)
535}
536
537type RepoBranchesParams struct {
538 LoggedInUser *oauth.User
539 RepoInfo repoinfo.RepoInfo
540 Active string
541 types.RepoBranchesResponse
542}
543
544func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
545 params.Active = "overview"
546 return p.executeRepo("repo/branches", w, params)
547}
548
549type RepoTagsParams struct {
550 LoggedInUser *oauth.User
551 RepoInfo repoinfo.RepoInfo
552 Active string
553 types.RepoTagsResponse
554 ArtifactMap map[plumbing.Hash][]db.Artifact
555 DanglingArtifacts []db.Artifact
556}
557
558func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
559 params.Active = "overview"
560 return p.executeRepo("repo/tags", w, params)
561}
562
563type RepoArtifactParams struct {
564 LoggedInUser *oauth.User
565 RepoInfo repoinfo.RepoInfo
566 Artifact db.Artifact
567}
568
569func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
570 return p.executePlain("repo/fragments/artifact", w, params)
571}
572
573type RepoBlobParams struct {
574 LoggedInUser *oauth.User
575 RepoInfo repoinfo.RepoInfo
576 Active string
577 BreadCrumbs [][]string
578 ShowRendered bool
579 RenderToggle bool
580 RenderedContents template.HTML
581 types.RepoBlobResponse
582}
583
584func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
585 var style *chroma.Style = styles.Get("catpuccin-latte")
586
587 if params.ShowRendered {
588 switch markup.GetFormat(params.Path) {
589 case markup.FormatMarkdown:
590 p.rctx.RepoInfo = params.RepoInfo
591 p.rctx.RendererType = markup.RendererTypeRepoMarkdown
592 htmlString := p.rctx.RenderMarkdown(params.Contents)
593 params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
594 }
595 }
596
597 if params.Lines < 5000 {
598 c := params.Contents
599 formatter := chromahtml.New(
600 chromahtml.InlineCode(false),
601 chromahtml.WithLineNumbers(true),
602 chromahtml.WithLinkableLineNumbers(true, "L"),
603 chromahtml.Standalone(false),
604 chromahtml.WithClasses(true),
605 )
606
607 lexer := lexers.Get(filepath.Base(params.Path))
608 if lexer == nil {
609 lexer = lexers.Fallback
610 }
611
612 iterator, err := lexer.Tokenise(nil, c)
613 if err != nil {
614 return fmt.Errorf("chroma tokenize: %w", err)
615 }
616
617 var code bytes.Buffer
618 err = formatter.Format(&code, style, iterator)
619 if err != nil {
620 return fmt.Errorf("chroma format: %w", err)
621 }
622
623 params.Contents = code.String()
624 }
625
626 params.Active = "overview"
627 return p.executeRepo("repo/blob", w, params)
628}
629
630type Collaborator struct {
631 Did string
632 Handle string
633 Role string
634}
635
636type RepoSettingsParams struct {
637 LoggedInUser *oauth.User
638 RepoInfo repoinfo.RepoInfo
639 Collaborators []Collaborator
640 Active string
641 Branches []types.Branch
642 Spindles []string
643 CurrentSpindle string
644 // TODO: use repoinfo.roles
645 IsCollaboratorInviteAllowed bool
646}
647
648func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
649 params.Active = "settings"
650 return p.executeRepo("repo/settings", w, params)
651}
652
653type RepoIssuesParams struct {
654 LoggedInUser *oauth.User
655 RepoInfo repoinfo.RepoInfo
656 Active string
657 Issues []db.Issue
658 DidHandleMap map[string]string
659 Page pagination.Page
660 FilteringByOpen bool
661}
662
663func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
664 params.Active = "issues"
665 return p.executeRepo("repo/issues/issues", w, params)
666}
667
668type RepoSingleIssueParams struct {
669 LoggedInUser *oauth.User
670 RepoInfo repoinfo.RepoInfo
671 Active string
672 Issue db.Issue
673 Comments []db.Comment
674 IssueOwnerHandle string
675 DidHandleMap map[string]string
676
677 State string
678}
679
680func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
681 params.Active = "issues"
682 if params.Issue.Open {
683 params.State = "open"
684 } else {
685 params.State = "closed"
686 }
687 return p.execute("repo/issues/issue", w, params)
688}
689
690type RepoNewIssueParams struct {
691 LoggedInUser *oauth.User
692 RepoInfo repoinfo.RepoInfo
693 Active string
694}
695
696func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
697 params.Active = "issues"
698 return p.executeRepo("repo/issues/new", w, params)
699}
700
701type EditIssueCommentParams struct {
702 LoggedInUser *oauth.User
703 RepoInfo repoinfo.RepoInfo
704 Issue *db.Issue
705 Comment *db.Comment
706}
707
708func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
709 return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
710}
711
712type SingleIssueCommentParams struct {
713 LoggedInUser *oauth.User
714 DidHandleMap map[string]string
715 RepoInfo repoinfo.RepoInfo
716 Issue *db.Issue
717 Comment *db.Comment
718}
719
720func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
721 return p.executePlain("repo/issues/fragments/issueComment", w, params)
722}
723
724type RepoNewPullParams struct {
725 LoggedInUser *oauth.User
726 RepoInfo repoinfo.RepoInfo
727 Branches []types.Branch
728 Strategy string
729 SourceBranch string
730 TargetBranch string
731 Title string
732 Body string
733 Active string
734}
735
736func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
737 params.Active = "pulls"
738 return p.executeRepo("repo/pulls/new", w, params)
739}
740
741type RepoPullsParams struct {
742 LoggedInUser *oauth.User
743 RepoInfo repoinfo.RepoInfo
744 Pulls []*db.Pull
745 Active string
746 DidHandleMap map[string]string
747 FilteringBy db.PullState
748 Stacks map[string]db.Stack
749}
750
751func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
752 params.Active = "pulls"
753 return p.executeRepo("repo/pulls/pulls", w, params)
754}
755
756type ResubmitResult uint64
757
758const (
759 ShouldResubmit ResubmitResult = iota
760 ShouldNotResubmit
761 Unknown
762)
763
764func (r ResubmitResult) Yes() bool {
765 return r == ShouldResubmit
766}
767func (r ResubmitResult) No() bool {
768 return r == ShouldNotResubmit
769}
770func (r ResubmitResult) Unknown() bool {
771 return r == Unknown
772}
773
774type RepoSinglePullParams struct {
775 LoggedInUser *oauth.User
776 RepoInfo repoinfo.RepoInfo
777 Active string
778 DidHandleMap map[string]string
779 Pull *db.Pull
780 Stack db.Stack
781 AbandonedPulls []*db.Pull
782 MergeCheck types.MergeCheckResponse
783 ResubmitCheck ResubmitResult
784}
785
786func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
787 params.Active = "pulls"
788 return p.executeRepo("repo/pulls/pull", w, params)
789}
790
791type RepoPullPatchParams struct {
792 LoggedInUser *oauth.User
793 DidHandleMap map[string]string
794 RepoInfo repoinfo.RepoInfo
795 Pull *db.Pull
796 Stack db.Stack
797 Diff *types.NiceDiff
798 Round int
799 Submission *db.PullSubmission
800}
801
802// this name is a mouthful
803func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
804 return p.execute("repo/pulls/patch", w, params)
805}
806
807type RepoPullInterdiffParams struct {
808 LoggedInUser *oauth.User
809 DidHandleMap map[string]string
810 RepoInfo repoinfo.RepoInfo
811 Pull *db.Pull
812 Round int
813 Interdiff *patchutil.InterdiffResult
814}
815
816// this name is a mouthful
817func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
818 return p.execute("repo/pulls/interdiff", w, params)
819}
820
821type PullPatchUploadParams struct {
822 RepoInfo repoinfo.RepoInfo
823}
824
825func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
826 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
827}
828
829type PullCompareBranchesParams struct {
830 RepoInfo repoinfo.RepoInfo
831 Branches []types.Branch
832 SourceBranch string
833}
834
835func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
836 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
837}
838
839type PullCompareForkParams struct {
840 RepoInfo repoinfo.RepoInfo
841 Forks []db.Repo
842 Selected string
843}
844
845func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
846 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
847}
848
849type PullCompareForkBranchesParams struct {
850 RepoInfo repoinfo.RepoInfo
851 SourceBranches []types.Branch
852 TargetBranches []types.Branch
853}
854
855func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
856 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
857}
858
859type PullResubmitParams struct {
860 LoggedInUser *oauth.User
861 RepoInfo repoinfo.RepoInfo
862 Pull *db.Pull
863 SubmissionId int
864}
865
866func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
867 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
868}
869
870type PullActionsParams struct {
871 LoggedInUser *oauth.User
872 RepoInfo repoinfo.RepoInfo
873 Pull *db.Pull
874 RoundNumber int
875 MergeCheck types.MergeCheckResponse
876 ResubmitCheck ResubmitResult
877 Stack db.Stack
878}
879
880func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
881 return p.executePlain("repo/pulls/fragments/pullActions", w, params)
882}
883
884type PullNewCommentParams struct {
885 LoggedInUser *oauth.User
886 RepoInfo repoinfo.RepoInfo
887 Pull *db.Pull
888 RoundNumber int
889}
890
891func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
892 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
893}
894
895type RepoCompareParams struct {
896 LoggedInUser *oauth.User
897 RepoInfo repoinfo.RepoInfo
898 Forks []db.Repo
899 Branches []types.Branch
900 Tags []*types.TagReference
901 Base string
902 Head string
903 Diff *types.NiceDiff
904
905 Active string
906}
907
908func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error {
909 params.Active = "overview"
910 return p.executeRepo("repo/compare/compare", w, params)
911}
912
913type RepoCompareNewParams struct {
914 LoggedInUser *oauth.User
915 RepoInfo repoinfo.RepoInfo
916 Forks []db.Repo
917 Branches []types.Branch
918 Tags []*types.TagReference
919 Base string
920 Head string
921
922 Active string
923}
924
925func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error {
926 params.Active = "overview"
927 return p.executeRepo("repo/compare/new", w, params)
928}
929
930type RepoCompareAllowPullParams struct {
931 LoggedInUser *oauth.User
932 RepoInfo repoinfo.RepoInfo
933 Base string
934 Head string
935}
936
937func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error {
938 return p.executePlain("repo/fragments/compareAllowPull", w, params)
939}
940
941type RepoCompareDiffParams struct {
942 LoggedInUser *oauth.User
943 RepoInfo repoinfo.RepoInfo
944 Diff types.NiceDiff
945}
946
947func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error {
948 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
949}
950
951func (p *Pages) Static() http.Handler {
952 if p.dev {
953 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
954 }
955
956 sub, err := fs.Sub(Files, "static")
957 if err != nil {
958 log.Fatalf("no static dir found? that's crazy: %v", err)
959 }
960 // Custom handler to apply Cache-Control headers for font files
961 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
962}
963
964func Cache(h http.Handler) http.Handler {
965 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
966 path := strings.Split(r.URL.Path, "?")[0]
967
968 if strings.HasSuffix(path, ".css") {
969 // on day for css files
970 w.Header().Set("Cache-Control", "public, max-age=86400")
971 } else {
972 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
973 }
974 h.ServeHTTP(w, r)
975 })
976}
977
978func CssContentHash() string {
979 cssFile, err := Files.Open("static/tw.css")
980 if err != nil {
981 log.Printf("Error opening CSS file: %v", err)
982 return ""
983 }
984 defer cssFile.Close()
985
986 hasher := sha256.New()
987 if _, err := io.Copy(hasher, cssFile); err != nil {
988 log.Printf("Error hashing CSS file: %v", err)
989 return ""
990 }
991
992 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
993}
994
995func (p *Pages) Error500(w io.Writer) error {
996 return p.execute("errors/500", w, nil)
997}
998
999func (p *Pages) Error404(w io.Writer) error {
1000 return p.execute("errors/404", w, nil)
1001}
1002
1003func (p *Pages) Error503(w io.Writer) error {
1004 return p.execute("errors/503", w, nil)
1005}