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 types.RepoCommitResponse
512 EmailToDidOrHandle map[string]string
513}
514
515func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
516 params.Active = "overview"
517 return p.executeRepo("repo/commit", w, params)
518}
519
520type RepoTreeParams struct {
521 LoggedInUser *auth.User
522 RepoInfo RepoInfo
523 Active string
524 BreadCrumbs [][]string
525 BaseTreeLink string
526 BaseBlobLink string
527 types.RepoTreeResponse
528}
529
530type RepoTreeStats struct {
531 NumFolders uint64
532 NumFiles uint64
533}
534
535func (r RepoTreeParams) TreeStats() RepoTreeStats {
536 numFolders, numFiles := 0, 0
537 for _, f := range r.Files {
538 if !f.IsFile {
539 numFolders += 1
540 } else if f.IsFile {
541 numFiles += 1
542 }
543 }
544
545 return RepoTreeStats{
546 NumFolders: uint64(numFolders),
547 NumFiles: uint64(numFiles),
548 }
549}
550
551func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
552 params.Active = "overview"
553 return p.execute("repo/tree", w, params)
554}
555
556type RepoBranchesParams struct {
557 LoggedInUser *auth.User
558 RepoInfo RepoInfo
559 types.RepoBranchesResponse
560}
561
562func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
563 return p.executeRepo("repo/branches", w, params)
564}
565
566type RepoTagsParams struct {
567 LoggedInUser *auth.User
568 RepoInfo RepoInfo
569 types.RepoTagsResponse
570}
571
572func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
573 return p.executeRepo("repo/tags", w, params)
574}
575
576type RepoBlobParams struct {
577 LoggedInUser *auth.User
578 RepoInfo RepoInfo
579 Active string
580 BreadCrumbs [][]string
581 ShowRendered bool
582 RenderToggle bool
583 RenderedContents template.HTML
584 types.RepoBlobResponse
585}
586
587func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
588 var style *chroma.Style = styles.Get("catpuccin-latte")
589
590 if params.ShowRendered {
591 switch markup.GetFormat(params.Path) {
592 case markup.FormatMarkdown:
593 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents))
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 *auth.User
638 RepoInfo RepoInfo
639 Collaborators []Collaborator
640 Active string
641 Branches []string
642 DefaultBranch string
643 // TODO: use repoinfo.roles
644 IsCollaboratorInviteAllowed bool
645}
646
647func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
648 params.Active = "settings"
649 return p.executeRepo("repo/settings", w, params)
650}
651
652type RepoIssuesParams struct {
653 LoggedInUser *auth.User
654 RepoInfo RepoInfo
655 Active string
656 Issues []db.Issue
657 DidHandleMap map[string]string
658
659 FilteringByOpen bool
660}
661
662func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
663 params.Active = "issues"
664 return p.executeRepo("repo/issues/issues", w, params)
665}
666
667type RepoSingleIssueParams struct {
668 LoggedInUser *auth.User
669 RepoInfo RepoInfo
670 Active string
671 Issue db.Issue
672 Comments []db.Comment
673 IssueOwnerHandle string
674 DidHandleMap map[string]string
675
676 State string
677}
678
679func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
680 params.Active = "issues"
681 if params.Issue.Open {
682 params.State = "open"
683 } else {
684 params.State = "closed"
685 }
686 return p.execute("repo/issues/issue", w, params)
687}
688
689type RepoNewIssueParams struct {
690 LoggedInUser *auth.User
691 RepoInfo RepoInfo
692 Active string
693}
694
695func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
696 params.Active = "issues"
697 return p.executeRepo("repo/issues/new", w, params)
698}
699
700type EditIssueCommentParams struct {
701 LoggedInUser *auth.User
702 RepoInfo RepoInfo
703 Issue *db.Issue
704 Comment *db.Comment
705}
706
707func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
708 return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
709}
710
711type SingleIssueCommentParams struct {
712 LoggedInUser *auth.User
713 DidHandleMap map[string]string
714 RepoInfo RepoInfo
715 Issue *db.Issue
716 Comment *db.Comment
717}
718
719func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
720 return p.executePlain("repo/issues/fragments/issueComment", w, params)
721}
722
723type RepoNewPullParams struct {
724 LoggedInUser *auth.User
725 RepoInfo RepoInfo
726 Branches []types.Branch
727 Active string
728}
729
730func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
731 params.Active = "pulls"
732 return p.executeRepo("repo/pulls/new", w, params)
733}
734
735type RepoPullsParams struct {
736 LoggedInUser *auth.User
737 RepoInfo RepoInfo
738 Pulls []*db.Pull
739 Active string
740 DidHandleMap map[string]string
741 FilteringBy db.PullState
742}
743
744func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
745 params.Active = "pulls"
746 return p.executeRepo("repo/pulls/pulls", w, params)
747}
748
749type ResubmitResult uint64
750
751const (
752 ShouldResubmit ResubmitResult = iota
753 ShouldNotResubmit
754 Unknown
755)
756
757func (r ResubmitResult) Yes() bool {
758 return r == ShouldResubmit
759}
760func (r ResubmitResult) No() bool {
761 return r == ShouldNotResubmit
762}
763func (r ResubmitResult) Unknown() bool {
764 return r == Unknown
765}
766
767type RepoSinglePullParams struct {
768 LoggedInUser *auth.User
769 RepoInfo RepoInfo
770 Active string
771 DidHandleMap map[string]string
772 Pull *db.Pull
773 MergeCheck types.MergeCheckResponse
774 ResubmitCheck ResubmitResult
775}
776
777func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
778 params.Active = "pulls"
779 return p.executeRepo("repo/pulls/pull", w, params)
780}
781
782type RepoPullPatchParams struct {
783 LoggedInUser *auth.User
784 DidHandleMap map[string]string
785 RepoInfo RepoInfo
786 Pull *db.Pull
787 Diff types.NiceDiff
788 Round int
789 Submission *db.PullSubmission
790}
791
792// this name is a mouthful
793func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
794 return p.execute("repo/pulls/patch", w, params)
795}
796
797type RepoPullInterdiffParams struct {
798 LoggedInUser *auth.User
799 DidHandleMap map[string]string
800 RepoInfo RepoInfo
801 Pull *db.Pull
802 Round int
803 Interdiff *patchutil.InterdiffResult
804}
805
806// this name is a mouthful
807func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
808 return p.execute("repo/pulls/interdiff", w, params)
809}
810
811type PullPatchUploadParams struct {
812 RepoInfo RepoInfo
813}
814
815func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
816 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
817}
818
819type PullCompareBranchesParams struct {
820 RepoInfo RepoInfo
821 Branches []types.Branch
822}
823
824func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
825 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
826}
827
828type PullCompareForkParams struct {
829 RepoInfo RepoInfo
830 Forks []db.Repo
831}
832
833func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
834 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
835}
836
837type PullCompareForkBranchesParams struct {
838 RepoInfo RepoInfo
839 SourceBranches []types.Branch
840 TargetBranches []types.Branch
841}
842
843func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
844 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
845}
846
847type PullResubmitParams struct {
848 LoggedInUser *auth.User
849 RepoInfo RepoInfo
850 Pull *db.Pull
851 SubmissionId int
852}
853
854func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
855 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
856}
857
858type PullActionsParams struct {
859 LoggedInUser *auth.User
860 RepoInfo RepoInfo
861 Pull *db.Pull
862 RoundNumber int
863 MergeCheck types.MergeCheckResponse
864 ResubmitCheck ResubmitResult
865}
866
867func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
868 return p.executePlain("repo/pulls/fragments/pullActions", w, params)
869}
870
871type PullNewCommentParams struct {
872 LoggedInUser *auth.User
873 RepoInfo RepoInfo
874 Pull *db.Pull
875 RoundNumber int
876}
877
878func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
879 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
880}
881
882func (p *Pages) Static() http.Handler {
883 if p.dev {
884 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
885 }
886
887 sub, err := fs.Sub(Files, "static")
888 if err != nil {
889 log.Fatalf("no static dir found? that's crazy: %v", err)
890 }
891 // Custom handler to apply Cache-Control headers for font files
892 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
893}
894
895func Cache(h http.Handler) http.Handler {
896 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
897 path := strings.Split(r.URL.Path, "?")[0]
898
899 if strings.HasSuffix(path, ".css") {
900 // on day for css files
901 w.Header().Set("Cache-Control", "public, max-age=86400")
902 } else {
903 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
904 }
905 h.ServeHTTP(w, r)
906 })
907}
908
909func CssContentHash() string {
910 cssFile, err := Files.Open("static/tw.css")
911 if err != nil {
912 log.Printf("Error opening CSS file: %v", err)
913 return ""
914 }
915 defer cssFile.Close()
916
917 hasher := sha256.New()
918 if _, err := io.Copy(hasher, cssFile); err != nil {
919 log.Printf("Error hashing CSS file: %v", err)
920 return ""
921 }
922
923 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
924}
925
926func (p *Pages) Error500(w io.Writer) error {
927 return p.execute("errors/500", w, nil)
928}
929
930func (p *Pages) Error404(w io.Writer) error {
931 return p.execute("errors/404", w, nil)
932}
933
934func (p *Pages) Error503(w io.Writer) error {
935 return p.execute("errors/503", w, nil)
936}