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