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