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