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}