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