forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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/slog" 13 "net/http" 14 "os" 15 "path/filepath" 16 "strings" 17 "sync" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" 21 "tangled.org/core/appview/config" 22 "tangled.org/core/appview/models" 23 "tangled.org/core/appview/oauth" 24 "tangled.org/core/appview/pages/markup" 25 "tangled.org/core/appview/pages/repoinfo" 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/patchutil" 29 "tangled.org/core/types" 30 31 "github.com/alecthomas/chroma/v2" 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 33 "github.com/alecthomas/chroma/v2/lexers" 34 "github.com/alecthomas/chroma/v2/styles" 35 "github.com/bluesky-social/indigo/atproto/identity" 36 "github.com/bluesky-social/indigo/atproto/syntax" 37 "github.com/go-git/go-git/v5/plumbing" 38 "github.com/go-git/go-git/v5/plumbing/object" 39) 40 41//go:embed templates/* static 42var Files embed.FS 43 44type Pages struct { 45 mu sync.RWMutex 46 cache *TmplCache[string, *template.Template] 47 48 avatar config.AvatarConfig 49 resolver *idresolver.Resolver 50 dev bool 51 embedFS fs.FS 52 templateDir string // Path to templates on disk for dev mode 53 rctx *markup.RenderContext 54 logger *slog.Logger 55} 56 57func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 58 // initialized with safe defaults, can be overriden per use 59 rctx := &markup.RenderContext{ 60 IsDev: config.Core.Dev, 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 } 65 66 p := &Pages{ 67 mu: sync.RWMutex{}, 68 cache: NewTmplCache[string, *template.Template](), 69 dev: config.Core.Dev, 70 avatar: config.Avatar, 71 rctx: rctx, 72 resolver: res, 73 templateDir: "appview/pages", 74 logger: slog.Default().With("component", "pages"), 75 } 76 77 if p.dev { 78 p.embedFS = os.DirFS(p.templateDir) 79 } else { 80 p.embedFS = Files 81 } 82 83 return p 84} 85 86// reverse of pathToName 87func (p *Pages) nameToPath(s string) string { 88 return "templates/" + s + ".html" 89} 90 91func (p *Pages) fragmentPaths() ([]string, error) { 92 var fragmentPaths []string 93 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 94 if err != nil { 95 return err 96 } 97 if d.IsDir() { 98 return nil 99 } 100 if !strings.HasSuffix(path, ".html") { 101 return nil 102 } 103 if !strings.Contains(path, "fragments/") { 104 return nil 105 } 106 fragmentPaths = append(fragmentPaths, path) 107 return nil 108 }) 109 if err != nil { 110 return nil, err 111 } 112 113 return fragmentPaths, nil 114} 115 116// parse without memoization 117func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 118 paths, err := p.fragmentPaths() 119 if err != nil { 120 return nil, err 121 } 122 for _, s := range stack { 123 paths = append(paths, p.nameToPath(s)) 124 } 125 126 funcs := p.funcMap() 127 top := stack[len(stack)-1] 128 parsed, err := template.New(top). 129 Funcs(funcs). 130 ParseFS(p.embedFS, paths...) 131 if err != nil { 132 return nil, err 133 } 134 135 return parsed, nil 136} 137 138func (p *Pages) parse(stack ...string) (*template.Template, error) { 139 key := strings.Join(stack, "|") 140 141 // never cache in dev mode 142 if cached, exists := p.cache.Get(key); !p.dev && exists { 143 return cached, nil 144 } 145 146 result, err := p.rawParse(stack...) 147 if err != nil { 148 return nil, err 149 } 150 151 p.cache.Set(key, result) 152 return result, nil 153} 154 155func (p *Pages) parseBase(top string) (*template.Template, error) { 156 stack := []string{ 157 "layouts/base", 158 top, 159 } 160 return p.parse(stack...) 161} 162 163func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 164 stack := []string{ 165 "layouts/base", 166 "layouts/repobase", 167 top, 168 } 169 return p.parse(stack...) 170} 171 172func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 173 stack := []string{ 174 "layouts/base", 175 "layouts/profilebase", 176 top, 177 } 178 return p.parse(stack...) 179} 180 181func (p *Pages) executePlain(name string, w io.Writer, params any) error { 182 tpl, err := p.parse(name) 183 if err != nil { 184 return err 185 } 186 187 return tpl.Execute(w, params) 188} 189 190func (p *Pages) execute(name string, w io.Writer, params any) error { 191 tpl, err := p.parseBase(name) 192 if err != nil { 193 return err 194 } 195 196 return tpl.ExecuteTemplate(w, "layouts/base", params) 197} 198 199func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 200 tpl, err := p.parseRepoBase(name) 201 if err != nil { 202 return err 203 } 204 205 return tpl.ExecuteTemplate(w, "layouts/base", params) 206} 207 208func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 209 tpl, err := p.parseProfileBase(name) 210 if err != nil { 211 return err 212 } 213 214 return tpl.ExecuteTemplate(w, "layouts/base", params) 215} 216 217func (p *Pages) Favicon(w io.Writer) error { 218 return p.executePlain("fragments/dolly/silhouette", w, nil) 219} 220 221type LoginParams struct { 222 ReturnUrl string 223} 224 225func (p *Pages) Login(w io.Writer, params LoginParams) error { 226 return p.executePlain("user/login", w, params) 227} 228 229func (p *Pages) Signup(w io.Writer) error { 230 return p.executePlain("user/signup", w, nil) 231} 232 233func (p *Pages) CompleteSignup(w io.Writer) error { 234 return p.executePlain("user/completeSignup", w, nil) 235} 236 237type TermsOfServiceParams struct { 238 LoggedInUser *oauth.User 239 Content template.HTML 240} 241 242func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 243 filename := "terms.md" 244 filePath := filepath.Join("legal", filename) 245 markdownBytes, err := os.ReadFile(filePath) 246 if err != nil { 247 return fmt.Errorf("failed to read %s: %w", filename, err) 248 } 249 250 p.rctx.RendererType = markup.RendererTypeDefault 251 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 252 sanitized := p.rctx.SanitizeDefault(htmlString) 253 params.Content = template.HTML(sanitized) 254 255 return p.execute("legal/terms", w, params) 256} 257 258type PrivacyPolicyParams struct { 259 LoggedInUser *oauth.User 260 Content template.HTML 261} 262 263func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 264 filename := "privacy.md" 265 filePath := filepath.Join("legal", filename) 266 markdownBytes, err := os.ReadFile(filePath) 267 if err != nil { 268 return fmt.Errorf("failed to read %s: %w", filename, err) 269 } 270 271 p.rctx.RendererType = markup.RendererTypeDefault 272 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 273 sanitized := p.rctx.SanitizeDefault(htmlString) 274 params.Content = template.HTML(sanitized) 275 276 return p.execute("legal/privacy", w, params) 277} 278 279type TimelineParams struct { 280 LoggedInUser *oauth.User 281 Timeline []models.TimelineEvent 282 Repos []models.Repo 283} 284 285func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 286 return p.execute("timeline/timeline", w, params) 287} 288 289type UserProfileSettingsParams struct { 290 LoggedInUser *oauth.User 291 Tabs []map[string]any 292 Tab string 293} 294 295func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 296 return p.execute("user/settings/profile", w, params) 297} 298 299type UserKeysSettingsParams struct { 300 LoggedInUser *oauth.User 301 PubKeys []models.PublicKey 302 Tabs []map[string]any 303 Tab string 304} 305 306func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 307 return p.execute("user/settings/keys", w, params) 308} 309 310type UserEmailsSettingsParams struct { 311 LoggedInUser *oauth.User 312 Emails []models.Email 313 Tabs []map[string]any 314 Tab string 315} 316 317func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 318 return p.execute("user/settings/emails", w, params) 319} 320 321type UpgradeBannerParams struct { 322 Registrations []models.Registration 323 Spindles []models.Spindle 324} 325 326func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 327 return p.executePlain("banner", w, params) 328} 329 330type KnotsParams struct { 331 LoggedInUser *oauth.User 332 Registrations []models.Registration 333} 334 335func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 336 return p.execute("knots/index", w, params) 337} 338 339type KnotParams struct { 340 LoggedInUser *oauth.User 341 Registration *models.Registration 342 Members []string 343 Repos map[string][]models.Repo 344 IsOwner bool 345} 346 347func (p *Pages) Knot(w io.Writer, params KnotParams) error { 348 return p.execute("knots/dashboard", w, params) 349} 350 351type KnotListingParams struct { 352 *models.Registration 353} 354 355func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 356 return p.executePlain("knots/fragments/knotListing", w, params) 357} 358 359type SpindlesParams struct { 360 LoggedInUser *oauth.User 361 Spindles []models.Spindle 362} 363 364func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { 365 return p.execute("spindles/index", w, params) 366} 367 368type SpindleListingParams struct { 369 models.Spindle 370} 371 372func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 373 return p.executePlain("spindles/fragments/spindleListing", w, params) 374} 375 376type SpindleDashboardParams struct { 377 LoggedInUser *oauth.User 378 Spindle models.Spindle 379 Members []string 380 Repos map[string][]models.Repo 381} 382 383func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { 384 return p.execute("spindles/dashboard", w, params) 385} 386 387type NewRepoParams struct { 388 LoggedInUser *oauth.User 389 Knots []string 390} 391 392func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 393 return p.execute("repo/new", w, params) 394} 395 396type ForkRepoParams struct { 397 LoggedInUser *oauth.User 398 Knots []string 399 RepoInfo repoinfo.RepoInfo 400} 401 402func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 403 return p.execute("repo/fork", w, params) 404} 405 406type ProfileCard struct { 407 UserDid string 408 UserHandle string 409 FollowStatus models.FollowStatus 410 Punchcard *models.Punchcard 411 Profile *models.Profile 412 Stats ProfileStats 413 Active string 414} 415 416type ProfileStats struct { 417 RepoCount int64 418 StarredCount int64 419 StringCount int64 420 FollowersCount int64 421 FollowingCount int64 422} 423 424func (p *ProfileCard) GetTabs() [][]any { 425 tabs := [][]any{ 426 {"overview", "overview", "square-chart-gantt", nil}, 427 {"repos", "repos", "book-marked", p.Stats.RepoCount}, 428 {"starred", "starred", "star", p.Stats.StarredCount}, 429 {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 430 } 431 432 return tabs 433} 434 435type ProfileOverviewParams struct { 436 LoggedInUser *oauth.User 437 Repos []models.Repo 438 CollaboratingRepos []models.Repo 439 ProfileTimeline *models.ProfileTimeline 440 Card *ProfileCard 441 Active string 442} 443 444func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 445 params.Active = "overview" 446 return p.executeProfile("user/overview", w, params) 447} 448 449type ProfileReposParams struct { 450 LoggedInUser *oauth.User 451 Repos []models.Repo 452 Card *ProfileCard 453 Active string 454} 455 456func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 457 params.Active = "repos" 458 return p.executeProfile("user/repos", w, params) 459} 460 461type ProfileStarredParams struct { 462 LoggedInUser *oauth.User 463 Repos []models.Repo 464 Card *ProfileCard 465 Active string 466} 467 468func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 469 params.Active = "starred" 470 return p.executeProfile("user/starred", w, params) 471} 472 473type ProfileStringsParams struct { 474 LoggedInUser *oauth.User 475 Strings []models.String 476 Card *ProfileCard 477 Active string 478} 479 480func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 481 params.Active = "strings" 482 return p.executeProfile("user/strings", w, params) 483} 484 485type FollowCard struct { 486 UserDid string 487 FollowStatus models.FollowStatus 488 FollowersCount int64 489 FollowingCount int64 490 Profile *models.Profile 491} 492 493type ProfileFollowersParams struct { 494 LoggedInUser *oauth.User 495 Followers []FollowCard 496 Card *ProfileCard 497 Active string 498} 499 500func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 501 params.Active = "overview" 502 return p.executeProfile("user/followers", w, params) 503} 504 505type ProfileFollowingParams struct { 506 LoggedInUser *oauth.User 507 Following []FollowCard 508 Card *ProfileCard 509 Active string 510} 511 512func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 513 params.Active = "overview" 514 return p.executeProfile("user/following", w, params) 515} 516 517type FollowFragmentParams struct { 518 UserDid string 519 FollowStatus models.FollowStatus 520} 521 522func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 523 return p.executePlain("user/fragments/follow", w, params) 524} 525 526type EditBioParams struct { 527 LoggedInUser *oauth.User 528 Profile *models.Profile 529} 530 531func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { 532 return p.executePlain("user/fragments/editBio", w, params) 533} 534 535type EditPinsParams struct { 536 LoggedInUser *oauth.User 537 Profile *models.Profile 538 AllRepos []PinnedRepo 539} 540 541type PinnedRepo struct { 542 IsPinned bool 543 models.Repo 544} 545 546func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { 547 return p.executePlain("user/fragments/editPins", w, params) 548} 549 550type RepoStarFragmentParams struct { 551 IsStarred bool 552 RepoAt syntax.ATURI 553 Stats models.RepoStats 554} 555 556func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 557 return p.executePlain("repo/fragments/repoStar", w, params) 558} 559 560type RepoDescriptionParams struct { 561 RepoInfo repoinfo.RepoInfo 562} 563 564func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 565 return p.executePlain("repo/fragments/editRepoDescription", w, params) 566} 567 568func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 569 return p.executePlain("repo/fragments/repoDescription", w, params) 570} 571 572type RepoIndexParams struct { 573 LoggedInUser *oauth.User 574 RepoInfo repoinfo.RepoInfo 575 Active string 576 TagMap map[string][]string 577 CommitsTrunc []*object.Commit 578 TagsTrunc []*types.TagReference 579 BranchesTrunc []types.Branch 580 // ForkInfo *types.ForkInfo 581 HTMLReadme template.HTML 582 Raw bool 583 EmailToDidOrHandle map[string]string 584 VerifiedCommits commitverify.VerifiedCommits 585 Languages []types.RepoLanguageDetails 586 Pipelines map[string]models.Pipeline 587 NeedsKnotUpgrade bool 588 types.RepoIndexResponse 589} 590 591func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 592 params.Active = "overview" 593 if params.IsEmpty { 594 return p.executeRepo("repo/empty", w, params) 595 } 596 597 if params.NeedsKnotUpgrade { 598 return p.executeRepo("repo/needsUpgrade", w, params) 599 } 600 601 p.rctx.RepoInfo = params.RepoInfo 602 p.rctx.RepoInfo.Ref = params.Ref 603 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 604 605 if params.ReadmeFileName != "" { 606 ext := filepath.Ext(params.ReadmeFileName) 607 switch ext { 608 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 609 params.Raw = false 610 htmlString := p.rctx.RenderMarkdown(params.Readme) 611 sanitized := p.rctx.SanitizeDefault(htmlString) 612 params.HTMLReadme = template.HTML(sanitized) 613 default: 614 params.Raw = true 615 } 616 } 617 618 return p.executeRepo("repo/index", w, params) 619} 620 621type RepoLogParams struct { 622 LoggedInUser *oauth.User 623 RepoInfo repoinfo.RepoInfo 624 TagMap map[string][]string 625 types.RepoLogResponse 626 Active string 627 EmailToDidOrHandle map[string]string 628 VerifiedCommits commitverify.VerifiedCommits 629 Pipelines map[string]models.Pipeline 630} 631 632func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 633 params.Active = "overview" 634 return p.executeRepo("repo/log", w, params) 635} 636 637type RepoCommitParams struct { 638 LoggedInUser *oauth.User 639 RepoInfo repoinfo.RepoInfo 640 Active string 641 EmailToDidOrHandle map[string]string 642 Pipeline *models.Pipeline 643 DiffOpts types.DiffOpts 644 645 // singular because it's always going to be just one 646 VerifiedCommit commitverify.VerifiedCommits 647 648 types.RepoCommitResponse 649} 650 651func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 652 params.Active = "overview" 653 return p.executeRepo("repo/commit", w, params) 654} 655 656type RepoTreeParams struct { 657 LoggedInUser *oauth.User 658 RepoInfo repoinfo.RepoInfo 659 Active string 660 BreadCrumbs [][]string 661 TreePath string 662 Readme string 663 ReadmeFileName string 664 HTMLReadme template.HTML 665 Raw bool 666 types.RepoTreeResponse 667} 668 669type RepoTreeStats struct { 670 NumFolders uint64 671 NumFiles uint64 672} 673 674func (r RepoTreeParams) TreeStats() RepoTreeStats { 675 numFolders, numFiles := 0, 0 676 for _, f := range r.Files { 677 if !f.IsFile { 678 numFolders += 1 679 } else if f.IsFile { 680 numFiles += 1 681 } 682 } 683 684 return RepoTreeStats{ 685 NumFolders: uint64(numFolders), 686 NumFiles: uint64(numFiles), 687 } 688} 689 690func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 691 params.Active = "overview" 692 693 if params.ReadmeFileName != "" { 694 params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 695 696 ext := filepath.Ext(params.ReadmeFileName) 697 switch ext { 698 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 699 params.Raw = false 700 htmlString := p.rctx.RenderMarkdown(params.Readme) 701 sanitized := p.rctx.SanitizeDefault(htmlString) 702 params.HTMLReadme = template.HTML(sanitized) 703 default: 704 params.Raw = true 705 } 706 } 707 708 return p.executeRepo("repo/tree", w, params) 709} 710 711type RepoBranchesParams struct { 712 LoggedInUser *oauth.User 713 RepoInfo repoinfo.RepoInfo 714 Active string 715 types.RepoBranchesResponse 716} 717 718func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 719 params.Active = "overview" 720 return p.executeRepo("repo/branches", w, params) 721} 722 723type RepoTagsParams struct { 724 LoggedInUser *oauth.User 725 RepoInfo repoinfo.RepoInfo 726 Active string 727 types.RepoTagsResponse 728 ArtifactMap map[plumbing.Hash][]models.Artifact 729 DanglingArtifacts []models.Artifact 730} 731 732func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 733 params.Active = "overview" 734 return p.executeRepo("repo/tags", w, params) 735} 736 737type RepoArtifactParams struct { 738 LoggedInUser *oauth.User 739 RepoInfo repoinfo.RepoInfo 740 Artifact models.Artifact 741} 742 743func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { 744 return p.executePlain("repo/fragments/artifact", w, params) 745} 746 747type RepoBlobParams struct { 748 LoggedInUser *oauth.User 749 RepoInfo repoinfo.RepoInfo 750 Active string 751 Unsupported bool 752 IsImage bool 753 IsVideo bool 754 ContentSrc string 755 BreadCrumbs [][]string 756 ShowRendered bool 757 RenderToggle bool 758 RenderedContents template.HTML 759 *tangled.RepoBlob_Output 760 // Computed fields for template compatibility 761 Contents string 762 Lines int 763 SizeHint uint64 764 IsBinary bool 765} 766 767func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 768 var style *chroma.Style = styles.Get("catpuccin-latte") 769 770 if params.ShowRendered { 771 switch markup.GetFormat(params.Path) { 772 case markup.FormatMarkdown: 773 p.rctx.RepoInfo = params.RepoInfo 774 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 775 htmlString := p.rctx.RenderMarkdown(params.Contents) 776 sanitized := p.rctx.SanitizeDefault(htmlString) 777 params.RenderedContents = template.HTML(sanitized) 778 } 779 } 780 781 c := params.Contents 782 formatter := chromahtml.New( 783 chromahtml.InlineCode(false), 784 chromahtml.WithLineNumbers(true), 785 chromahtml.WithLinkableLineNumbers(true, "L"), 786 chromahtml.Standalone(false), 787 chromahtml.WithClasses(true), 788 ) 789 790 lexer := lexers.Get(filepath.Base(params.Path)) 791 if lexer == nil { 792 lexer = lexers.Fallback 793 } 794 795 iterator, err := lexer.Tokenise(nil, c) 796 if err != nil { 797 return fmt.Errorf("chroma tokenize: %w", err) 798 } 799 800 var code bytes.Buffer 801 err = formatter.Format(&code, style, iterator) 802 if err != nil { 803 return fmt.Errorf("chroma format: %w", err) 804 } 805 806 params.Contents = code.String() 807 params.Active = "overview" 808 return p.executeRepo("repo/blob", w, params) 809} 810 811type Collaborator struct { 812 Did string 813 Handle string 814 Role string 815} 816 817type RepoSettingsParams struct { 818 LoggedInUser *oauth.User 819 RepoInfo repoinfo.RepoInfo 820 Collaborators []Collaborator 821 Active string 822 Branches []types.Branch 823 Spindles []string 824 CurrentSpindle string 825 Secrets []*tangled.RepoListSecrets_Secret 826 827 // TODO: use repoinfo.roles 828 IsCollaboratorInviteAllowed bool 829} 830 831func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 832 params.Active = "settings" 833 return p.executeRepo("repo/settings", w, params) 834} 835 836type RepoGeneralSettingsParams struct { 837 LoggedInUser *oauth.User 838 RepoInfo repoinfo.RepoInfo 839 Labels []models.LabelDefinition 840 DefaultLabels []models.LabelDefinition 841 SubscribedLabels map[string]struct{} 842 ShouldSubscribeAll bool 843 Active string 844 Tabs []map[string]any 845 Tab string 846 Branches []types.Branch 847} 848 849func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 850 params.Active = "settings" 851 return p.executeRepo("repo/settings/general", w, params) 852} 853 854type RepoAccessSettingsParams struct { 855 LoggedInUser *oauth.User 856 RepoInfo repoinfo.RepoInfo 857 Active string 858 Tabs []map[string]any 859 Tab string 860 Collaborators []Collaborator 861} 862 863func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 864 params.Active = "settings" 865 return p.executeRepo("repo/settings/access", w, params) 866} 867 868type RepoPipelineSettingsParams struct { 869 LoggedInUser *oauth.User 870 RepoInfo repoinfo.RepoInfo 871 Active string 872 Tabs []map[string]any 873 Tab string 874 Spindles []string 875 CurrentSpindle string 876 Secrets []map[string]any 877} 878 879func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 880 params.Active = "settings" 881 return p.executeRepo("repo/settings/pipelines", w, params) 882} 883 884type RepoIssuesParams struct { 885 LoggedInUser *oauth.User 886 RepoInfo repoinfo.RepoInfo 887 Active string 888 Issues []models.Issue 889 LabelDefs map[string]*models.LabelDefinition 890 Page pagination.Page 891 FilteringByOpen bool 892} 893 894func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 895 params.Active = "issues" 896 return p.executeRepo("repo/issues/issues", w, params) 897} 898 899type RepoSingleIssueParams struct { 900 LoggedInUser *oauth.User 901 RepoInfo repoinfo.RepoInfo 902 Active string 903 Issue *models.Issue 904 CommentList []models.CommentListItem 905 LabelDefs map[string]*models.LabelDefinition 906 907 OrderedReactionKinds []models.ReactionKind 908 Reactions map[models.ReactionKind]int 909 UserReacted map[models.ReactionKind]bool 910} 911 912func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 913 params.Active = "issues" 914 return p.executeRepo("repo/issues/issue", w, params) 915} 916 917type EditIssueParams struct { 918 LoggedInUser *oauth.User 919 RepoInfo repoinfo.RepoInfo 920 Issue *models.Issue 921 Action string 922} 923 924func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 925 params.Action = "edit" 926 return p.executePlain("repo/issues/fragments/putIssue", w, params) 927} 928 929type ThreadReactionFragmentParams struct { 930 ThreadAt syntax.ATURI 931 Kind models.ReactionKind 932 Count int 933 IsReacted bool 934} 935 936func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 937 return p.executePlain("repo/fragments/reaction", w, params) 938} 939 940type RepoNewIssueParams struct { 941 LoggedInUser *oauth.User 942 RepoInfo repoinfo.RepoInfo 943 Issue *models.Issue // existing issue if any -- passed when editing 944 Active string 945 Action string 946} 947 948func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 949 params.Active = "issues" 950 params.Action = "create" 951 return p.executeRepo("repo/issues/new", w, params) 952} 953 954type EditIssueCommentParams struct { 955 LoggedInUser *oauth.User 956 RepoInfo repoinfo.RepoInfo 957 Issue *models.Issue 958 Comment *models.IssueComment 959} 960 961func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 962 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 963} 964 965type ReplyIssueCommentPlaceholderParams struct { 966 LoggedInUser *oauth.User 967 RepoInfo repoinfo.RepoInfo 968 Issue *models.Issue 969 Comment *models.IssueComment 970} 971 972func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 973 return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 974} 975 976type ReplyIssueCommentParams struct { 977 LoggedInUser *oauth.User 978 RepoInfo repoinfo.RepoInfo 979 Issue *models.Issue 980 Comment *models.IssueComment 981} 982 983func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 984 return p.executePlain("repo/issues/fragments/replyComment", w, params) 985} 986 987type IssueCommentBodyParams struct { 988 LoggedInUser *oauth.User 989 RepoInfo repoinfo.RepoInfo 990 Issue *models.Issue 991 Comment *models.IssueComment 992} 993 994func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 995 return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 996} 997 998type RepoNewPullParams struct { 999 LoggedInUser *oauth.User 1000 RepoInfo repoinfo.RepoInfo 1001 Branches []types.Branch 1002 Strategy string 1003 SourceBranch string 1004 TargetBranch string 1005 Title string 1006 Body string 1007 Active string 1008} 1009 1010func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 1011 params.Active = "pulls" 1012 return p.executeRepo("repo/pulls/new", w, params) 1013} 1014 1015type RepoPullsParams struct { 1016 LoggedInUser *oauth.User 1017 RepoInfo repoinfo.RepoInfo 1018 Pulls []*models.Pull 1019 Active string 1020 FilteringBy models.PullState 1021 Stacks map[string]models.Stack 1022 Pipelines map[string]models.Pipeline 1023} 1024 1025func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 1026 params.Active = "pulls" 1027 return p.executeRepo("repo/pulls/pulls", w, params) 1028} 1029 1030type ResubmitResult uint64 1031 1032const ( 1033 ShouldResubmit ResubmitResult = iota 1034 ShouldNotResubmit 1035 Unknown 1036) 1037 1038func (r ResubmitResult) Yes() bool { 1039 return r == ShouldResubmit 1040} 1041func (r ResubmitResult) No() bool { 1042 return r == ShouldNotResubmit 1043} 1044func (r ResubmitResult) Unknown() bool { 1045 return r == Unknown 1046} 1047 1048type RepoSinglePullParams struct { 1049 LoggedInUser *oauth.User 1050 RepoInfo repoinfo.RepoInfo 1051 Active string 1052 Pull *models.Pull 1053 Stack models.Stack 1054 AbandonedPulls []*models.Pull 1055 MergeCheck types.MergeCheckResponse 1056 ResubmitCheck ResubmitResult 1057 Pipelines map[string]models.Pipeline 1058 1059 OrderedReactionKinds []models.ReactionKind 1060 Reactions map[models.ReactionKind]int 1061 UserReacted map[models.ReactionKind]bool 1062} 1063 1064func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 1065 params.Active = "pulls" 1066 return p.executeRepo("repo/pulls/pull", w, params) 1067} 1068 1069type RepoPullPatchParams struct { 1070 LoggedInUser *oauth.User 1071 RepoInfo repoinfo.RepoInfo 1072 Pull *models.Pull 1073 Stack models.Stack 1074 Diff *types.NiceDiff 1075 Round int 1076 Submission *models.PullSubmission 1077 OrderedReactionKinds []models.ReactionKind 1078 DiffOpts types.DiffOpts 1079} 1080 1081// this name is a mouthful 1082func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 1083 return p.execute("repo/pulls/patch", w, params) 1084} 1085 1086type RepoPullInterdiffParams struct { 1087 LoggedInUser *oauth.User 1088 RepoInfo repoinfo.RepoInfo 1089 Pull *models.Pull 1090 Round int 1091 Interdiff *patchutil.InterdiffResult 1092 OrderedReactionKinds []models.ReactionKind 1093 DiffOpts types.DiffOpts 1094} 1095 1096// this name is a mouthful 1097func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 1098 return p.execute("repo/pulls/interdiff", w, params) 1099} 1100 1101type PullPatchUploadParams struct { 1102 RepoInfo repoinfo.RepoInfo 1103} 1104 1105func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 1106 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 1107} 1108 1109type PullCompareBranchesParams struct { 1110 RepoInfo repoinfo.RepoInfo 1111 Branches []types.Branch 1112 SourceBranch string 1113} 1114 1115func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 1116 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 1117} 1118 1119type PullCompareForkParams struct { 1120 RepoInfo repoinfo.RepoInfo 1121 Forks []models.Repo 1122 Selected string 1123} 1124 1125func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 1126 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 1127} 1128 1129type PullCompareForkBranchesParams struct { 1130 RepoInfo repoinfo.RepoInfo 1131 SourceBranches []types.Branch 1132 TargetBranches []types.Branch 1133} 1134 1135func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 1136 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 1137} 1138 1139type PullResubmitParams struct { 1140 LoggedInUser *oauth.User 1141 RepoInfo repoinfo.RepoInfo 1142 Pull *models.Pull 1143 SubmissionId int 1144} 1145 1146func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 1147 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 1148} 1149 1150type PullActionsParams struct { 1151 LoggedInUser *oauth.User 1152 RepoInfo repoinfo.RepoInfo 1153 Pull *models.Pull 1154 RoundNumber int 1155 MergeCheck types.MergeCheckResponse 1156 ResubmitCheck ResubmitResult 1157 Stack models.Stack 1158} 1159 1160func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 1161 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 1162} 1163 1164type PullNewCommentParams struct { 1165 LoggedInUser *oauth.User 1166 RepoInfo repoinfo.RepoInfo 1167 Pull *models.Pull 1168 RoundNumber int 1169} 1170 1171func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 1172 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 1173} 1174 1175type RepoCompareParams struct { 1176 LoggedInUser *oauth.User 1177 RepoInfo repoinfo.RepoInfo 1178 Forks []models.Repo 1179 Branches []types.Branch 1180 Tags []*types.TagReference 1181 Base string 1182 Head string 1183 Diff *types.NiceDiff 1184 DiffOpts types.DiffOpts 1185 1186 Active string 1187} 1188 1189func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error { 1190 params.Active = "overview" 1191 return p.executeRepo("repo/compare/compare", w, params) 1192} 1193 1194type RepoCompareNewParams struct { 1195 LoggedInUser *oauth.User 1196 RepoInfo repoinfo.RepoInfo 1197 Forks []models.Repo 1198 Branches []types.Branch 1199 Tags []*types.TagReference 1200 Base string 1201 Head string 1202 1203 Active string 1204} 1205 1206func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error { 1207 params.Active = "overview" 1208 return p.executeRepo("repo/compare/new", w, params) 1209} 1210 1211type RepoCompareAllowPullParams struct { 1212 LoggedInUser *oauth.User 1213 RepoInfo repoinfo.RepoInfo 1214 Base string 1215 Head string 1216} 1217 1218func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error { 1219 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1220} 1221 1222type RepoCompareDiffParams struct { 1223 LoggedInUser *oauth.User 1224 RepoInfo repoinfo.RepoInfo 1225 Diff types.NiceDiff 1226} 1227 1228func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1229 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1230} 1231 1232type LabelPanelParams struct { 1233 LoggedInUser *oauth.User 1234 RepoInfo repoinfo.RepoInfo 1235 Defs map[string]*models.LabelDefinition 1236 Subject string 1237 State models.LabelState 1238} 1239 1240func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { 1241 return p.executePlain("repo/fragments/labelPanel", w, params) 1242} 1243 1244type EditLabelPanelParams struct { 1245 LoggedInUser *oauth.User 1246 RepoInfo repoinfo.RepoInfo 1247 Defs map[string]*models.LabelDefinition 1248 Subject string 1249 State models.LabelState 1250} 1251 1252func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { 1253 return p.executePlain("repo/fragments/editLabelPanel", w, params) 1254} 1255 1256type PipelinesParams struct { 1257 LoggedInUser *oauth.User 1258 RepoInfo repoinfo.RepoInfo 1259 Pipelines []models.Pipeline 1260 Active string 1261} 1262 1263func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error { 1264 params.Active = "pipelines" 1265 return p.executeRepo("repo/pipelines/pipelines", w, params) 1266} 1267 1268type LogBlockParams struct { 1269 Id int 1270 Name string 1271 Command string 1272 Collapsed bool 1273} 1274 1275func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1276 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1277} 1278 1279type LogLineParams struct { 1280 Id int 1281 Content string 1282} 1283 1284func (p *Pages) LogLine(w io.Writer, params LogLineParams) error { 1285 return p.executePlain("repo/pipelines/fragments/logLine", w, params) 1286} 1287 1288type WorkflowParams struct { 1289 LoggedInUser *oauth.User 1290 RepoInfo repoinfo.RepoInfo 1291 Pipeline models.Pipeline 1292 Workflow string 1293 LogUrl string 1294 Active string 1295} 1296 1297func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1298 params.Active = "pipelines" 1299 return p.executeRepo("repo/pipelines/workflow", w, params) 1300} 1301 1302type PutStringParams struct { 1303 LoggedInUser *oauth.User 1304 Action string 1305 1306 // this is supplied in the case of editing an existing string 1307 String models.String 1308} 1309 1310func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1311 return p.execute("strings/put", w, params) 1312} 1313 1314type StringsDashboardParams struct { 1315 LoggedInUser *oauth.User 1316 Card ProfileCard 1317 Strings []models.String 1318} 1319 1320func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1321 return p.execute("strings/dashboard", w, params) 1322} 1323 1324type StringTimelineParams struct { 1325 LoggedInUser *oauth.User 1326 Strings []models.String 1327} 1328 1329func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1330 return p.execute("strings/timeline", w, params) 1331} 1332 1333type SingleStringParams struct { 1334 LoggedInUser *oauth.User 1335 ShowRendered bool 1336 RenderToggle bool 1337 RenderedContents template.HTML 1338 String models.String 1339 Stats models.StringStats 1340 Owner identity.Identity 1341} 1342 1343func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1344 var style *chroma.Style = styles.Get("catpuccin-latte") 1345 1346 if params.ShowRendered { 1347 switch markup.GetFormat(params.String.Filename) { 1348 case markup.FormatMarkdown: 1349 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1350 htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1351 sanitized := p.rctx.SanitizeDefault(htmlString) 1352 params.RenderedContents = template.HTML(sanitized) 1353 } 1354 } 1355 1356 c := params.String.Contents 1357 formatter := chromahtml.New( 1358 chromahtml.InlineCode(false), 1359 chromahtml.WithLineNumbers(true), 1360 chromahtml.WithLinkableLineNumbers(true, "L"), 1361 chromahtml.Standalone(false), 1362 chromahtml.WithClasses(true), 1363 ) 1364 1365 lexer := lexers.Get(filepath.Base(params.String.Filename)) 1366 if lexer == nil { 1367 lexer = lexers.Fallback 1368 } 1369 1370 iterator, err := lexer.Tokenise(nil, c) 1371 if err != nil { 1372 return fmt.Errorf("chroma tokenize: %w", err) 1373 } 1374 1375 var code bytes.Buffer 1376 err = formatter.Format(&code, style, iterator) 1377 if err != nil { 1378 return fmt.Errorf("chroma format: %w", err) 1379 } 1380 1381 params.String.Contents = code.String() 1382 return p.execute("strings/string", w, params) 1383} 1384 1385func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1386 return p.execute("timeline/home", w, params) 1387} 1388 1389func (p *Pages) Static() http.Handler { 1390 if p.dev { 1391 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1392 } 1393 1394 sub, err := fs.Sub(Files, "static") 1395 if err != nil { 1396 p.logger.Error("no static dir found? that's crazy", "err", err) 1397 panic(err) 1398 } 1399 // Custom handler to apply Cache-Control headers for font files 1400 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 1401} 1402 1403func Cache(h http.Handler) http.Handler { 1404 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1405 path := strings.Split(r.URL.Path, "?")[0] 1406 1407 if strings.HasSuffix(path, ".css") { 1408 // on day for css files 1409 w.Header().Set("Cache-Control", "public, max-age=86400") 1410 } else { 1411 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 1412 } 1413 h.ServeHTTP(w, r) 1414 }) 1415} 1416 1417func CssContentHash() string { 1418 cssFile, err := Files.Open("static/tw.css") 1419 if err != nil { 1420 slog.Debug("Error opening CSS file", "err", err) 1421 return "" 1422 } 1423 defer cssFile.Close() 1424 1425 hasher := sha256.New() 1426 if _, err := io.Copy(hasher, cssFile); err != nil { 1427 slog.Debug("Error hashing CSS file", "err", err) 1428 return "" 1429 } 1430 1431 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 1432} 1433 1434func (p *Pages) Error500(w io.Writer) error { 1435 return p.execute("errors/500", w, nil) 1436} 1437 1438func (p *Pages) Error404(w io.Writer) error { 1439 return p.execute("errors/404", w, nil) 1440} 1441 1442func (p *Pages) ErrorKnot404(w io.Writer) error { 1443 return p.execute("errors/knot404", w, nil) 1444} 1445 1446func (p *Pages) Error503(w io.Writer) error { 1447 return p.execute("errors/503", w, nil) 1448}