This repository has no description
1package pages
2
3import (
4 "context"
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 "time"
19
20 "tangled.org/core/api/tangled"
21 "tangled.org/core/appview/cache"
22 "tangled.org/core/appview/commitverify"
23 "tangled.org/core/appview/config"
24 "tangled.org/core/appview/db"
25 "tangled.org/core/appview/models"
26 "tangled.org/core/appview/oauth"
27 "tangled.org/core/appview/pages/markup"
28 "tangled.org/core/appview/pages/markup/sanitizer"
29 "tangled.org/core/appview/pages/repoinfo"
30 "tangled.org/core/appview/pagination"
31 "tangled.org/core/idresolver"
32 "tangled.org/core/types"
33
34 "github.com/bluesky-social/indigo/atproto/identity"
35 "github.com/bluesky-social/indigo/atproto/syntax"
36 "github.com/go-git/go-git/v5/plumbing"
37 "github.com/sourcegraph/zoekt"
38)
39
40//go:embed templates/* static legal
41var Files embed.FS
42
43type baseParamsCtxKey struct{}
44
45type BaseParams struct {
46 LoggedInUser *oauth.MultiAccountUser
47 FocusParams FocusParams
48}
49
50type FocusParams struct {
51 Focusing bool
52 FocusLink string
53 FocusNotificationID int64
54 CurrentPath string // r.URL.Path, for off-focus detection in templates
55 FocusCount int // total unread focus-eligible items remaining
56}
57
58func (p *Pages) Resolver() *idresolver.Resolver {
59 return p.resolver
60}
61
62func BaseParamsIntoContext(ctx context.Context, bp BaseParams) context.Context {
63 return context.WithValue(ctx, baseParamsCtxKey{}, bp)
64}
65
66func BaseParamsFromContext(ctx context.Context) BaseParams {
67 bp, _ := ctx.Value(baseParamsCtxKey{}).(BaseParams)
68 return bp
69}
70
71type Pages struct {
72 mu sync.RWMutex
73 cache *TmplCache[string, *template.Template]
74
75 avatar config.AvatarConfig
76 pdsCfg config.PdsConfig
77 resolver *idresolver.Resolver
78 db *db.DB
79 rdb *cache.Cache
80 dev bool
81 embedFS fs.FS
82 templateDir string // Path to templates on disk for dev mode
83 rctx *markup.RenderContext
84 logger *slog.Logger
85}
86
87func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, rdb *cache.Cache, logger *slog.Logger) *Pages {
88 // initialized with safe defaults, can be overridden per use
89 rctx := &markup.RenderContext{
90 IsDev: config.Core.Dev,
91 Hostname: config.Core.AppviewHost,
92 CamoUrl: config.Camo.Host,
93 CamoSecret: config.Camo.SharedSecret,
94 Files: Files,
95 }
96
97 p := &Pages{
98 mu: sync.RWMutex{},
99 cache: NewTmplCache[string, *template.Template](),
100 dev: config.Core.Dev,
101 avatar: config.Avatar,
102 pdsCfg: config.Pds,
103 rctx: rctx,
104 resolver: res,
105 db: database,
106 rdb: rdb,
107 templateDir: "appview/pages",
108 logger: logger,
109 }
110
111 if p.dev {
112 p.embedFS = os.DirFS(p.templateDir)
113 } else {
114 p.embedFS = Files
115 }
116
117 return p
118}
119
120// reverse of pathToName
121func (p *Pages) nameToPath(s string) string {
122 return "templates/" + s + ".html"
123}
124
125// FuncMap returns the template function map for use by external template consumers.
126func (p *Pages) FuncMap() template.FuncMap {
127 return p.funcMap()
128}
129
130// FragmentPaths returns all fragment template paths from the embedded FS.
131func (p *Pages) FragmentPaths() ([]string, error) {
132 return p.fragmentPaths()
133}
134
135// EmbedFS returns the embedded filesystem containing templates and static assets.
136func (p *Pages) EmbedFS() fs.FS {
137 return p.embedFS
138}
139
140func (p *Pages) fragmentPaths() ([]string, error) {
141 var fragmentPaths []string
142 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
143 if err != nil {
144 return err
145 }
146 if d.IsDir() {
147 return nil
148 }
149 if !strings.HasSuffix(path, ".html") {
150 return nil
151 }
152 if !strings.Contains(path, "fragments/") {
153 return nil
154 }
155 fragmentPaths = append(fragmentPaths, path)
156 return nil
157 })
158 if err != nil {
159 return nil, err
160 }
161
162 return fragmentPaths, nil
163}
164
165// parse without memoization
166func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
167 paths, err := p.fragmentPaths()
168 if err != nil {
169 return nil, err
170 }
171 for _, s := range stack {
172 paths = append(paths, p.nameToPath(s))
173 }
174
175 funcs := p.funcMap()
176 top := stack[len(stack)-1]
177 parsed, err := template.New(top).
178 Funcs(funcs).
179 ParseFS(p.embedFS, paths...)
180 if err != nil {
181 return nil, err
182 }
183
184 return parsed, nil
185}
186
187func (p *Pages) parse(stack ...string) (*template.Template, error) {
188 key := strings.Join(stack, "|")
189
190 // never cache in dev mode
191 if cached, exists := p.cache.Get(key); !p.dev && exists {
192 return cached, nil
193 }
194
195 result, err := p.rawParse(stack...)
196 if err != nil {
197 return nil, err
198 }
199
200 p.cache.Set(key, result)
201 return result, nil
202}
203
204func (p *Pages) parseBase(top string) (*template.Template, error) {
205 stack := []string{
206 "layouts/base",
207 top,
208 }
209 return p.parse(stack...)
210}
211
212func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
213 stack := []string{
214 "layouts/base",
215 "layouts/repobase",
216 top,
217 }
218 return p.parse(stack...)
219}
220
221func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
222 stack := []string{
223 "layouts/base",
224 "layouts/profilebase",
225 top,
226 }
227 return p.parse(stack...)
228}
229
230func (p *Pages) parseLoginBase(top string) (*template.Template, error) {
231 stack := []string{
232 "layouts/base",
233 "layouts/loginbase",
234 top,
235 }
236 return p.parse(stack...)
237}
238
239func (p *Pages) parseOnboardingBase(top string) (*template.Template, error) {
240 stack := []string{
241 "layouts/base",
242 "layouts/onboardingbase",
243 top,
244 }
245 return p.parse(stack...)
246}
247
248func (p *Pages) executePlain(name string, w io.Writer, params any) error {
249 tpl, err := p.parse(name)
250 if err != nil {
251 return err
252 }
253
254 err = tpl.Execute(w, params)
255 if err != nil {
256 p.logger.Error("failed to execute template", "template", name, "err", err)
257 }
258 return err
259}
260
261func (p *Pages) executeLogin(name string, w io.Writer, params any) error {
262 tpl, err := p.parseLoginBase(name)
263 if err != nil {
264 return err
265 }
266
267 err = tpl.ExecuteTemplate(w, "layouts/base", params)
268 if err != nil {
269 p.logger.Error("failed to execute login template", "template", name, "err", err)
270 }
271 return err
272}
273
274func (p *Pages) executeOnboarding(name string, w io.Writer, params any) error {
275 tpl, err := p.parseOnboardingBase(name)
276 if err != nil {
277 return err
278 }
279
280 return tpl.ExecuteTemplate(w, "layouts/base", params)
281}
282
283func (p *Pages) execute(name string, w io.Writer, params any) error {
284 tpl, err := p.parseBase(name)
285 if err != nil {
286 return err
287 }
288
289 err = tpl.ExecuteTemplate(w, "layouts/base", params)
290 if err != nil {
291 p.logger.Error("failed to execute template", "template", name, "err", err)
292 }
293 return err
294}
295
296func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
297 tpl, err := p.parseRepoBase(name)
298 if err != nil {
299 return err
300 }
301
302 err = tpl.ExecuteTemplate(w, "layouts/base", params)
303 if err != nil {
304 p.logger.Error("failed to execute repo template", "template", name, "err", err)
305 }
306 return err
307}
308
309func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
310 tpl, err := p.parseProfileBase(name)
311 if err != nil {
312 return err
313 }
314
315 err = tpl.ExecuteTemplate(w, "layouts/base", params)
316 if err != nil {
317 p.logger.Error("failed to execute profile template", "template", name, "err", err)
318 }
319 return err
320}
321
322type DollyParams struct {
323 Classes string
324 FillColor string
325 // Favicon embeds a prefers-color-scheme style block so the SVG
326 // adapts to dark mode when used as a standalone favicon document.
327 Favicon bool
328}
329
330func (p *Pages) Dolly(w io.Writer, params DollyParams) error {
331 return p.executePlain("fragments/dolly/logo", w, params)
332}
333
334func (p *Pages) Favicon(w io.Writer) error {
335 return p.Dolly(w, DollyParams{
336 Favicon: true,
337 })
338}
339
340type LoginParams struct {
341 ReturnUrl string
342 ErrorCode string
343 AddAccount bool
344 Accounts []oauth.AccountInfo
345}
346
347func (p *Pages) Login(w io.Writer, params LoginParams) error {
348 return p.executeLogin("user/login", w, params)
349}
350
351type SignupParams struct {
352 CloudflareSiteKey string
353 EmailId string
354}
355
356func (p *Pages) Signup(w io.Writer, params SignupParams) error {
357 return p.executeLogin("user/signup", w, params)
358}
359
360func (p *Pages) CompleteSignup(w io.Writer) error {
361 return p.executeLogin("user/completeSignup", w, nil)
362}
363
364type TermsOfServiceParams struct {
365 BaseParams
366 Content template.HTML
367}
368
369func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
370 filename := "terms.md"
371 filePath := filepath.Join("legal", filename)
372
373 file, err := p.embedFS.Open(filePath)
374 if err != nil {
375 return fmt.Errorf("failed to read %s: %w", filename, err)
376 }
377 defer file.Close()
378
379 markdownBytes, err := io.ReadAll(file)
380 if err != nil {
381 return fmt.Errorf("failed to read %s: %w", filename, err)
382 }
383
384 rctx := p.rctx.Clone()
385 rctx.RendererType = markup.RendererTypeDefault
386 htmlString := rctx.RenderMarkdown(string(markdownBytes))
387 sanitized := sanitizer.SanitizeDefault(htmlString)
388 params.Content = template.HTML(sanitized)
389
390 return p.execute("legal/terms", w, params)
391}
392
393type PrivacyPolicyParams struct {
394 BaseParams
395 Content template.HTML
396}
397
398func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
399 filename := "privacy.md"
400 filePath := filepath.Join("legal", filename)
401
402 file, err := p.embedFS.Open(filePath)
403 if err != nil {
404 return fmt.Errorf("failed to read %s: %w", filename, err)
405 }
406 defer file.Close()
407
408 markdownBytes, err := io.ReadAll(file)
409 if err != nil {
410 return fmt.Errorf("failed to read %s: %w", filename, err)
411 }
412
413 rctx := p.rctx.Clone()
414 rctx.RendererType = markup.RendererTypeDefault
415 htmlString := rctx.RenderMarkdown(string(markdownBytes))
416 sanitized := sanitizer.SanitizeDefault(htmlString)
417 params.Content = template.HTML(sanitized)
418
419 return p.execute("legal/privacy", w, params)
420}
421
422type BrandParams struct {
423 BaseParams
424}
425
426func (p *Pages) Brand(w io.Writer, params BrandParams) error {
427 return p.execute("brand/brand", w, params)
428}
429
430type RecentItem struct {
431 Link *models.RecentLink
432 Repo *models.Repo
433 Issue *models.Issue
434 Pull *models.Pull
435}
436
437type BlogPost struct {
438 Slug string
439 Title string
440 Subtitle string
441 Date time.Time
442}
443
444type TimelineParams struct {
445 BaseParams
446 Onboarding models.OnboardingProgress
447 Timeline []models.TimelineGroup
448 Repos []models.Repo
449 GfiLabel *models.LabelDefinition
450 BlueskyPosts []models.BskyPost
451 VouchSuggestions []models.VouchSuggestion
452 Notifications []*models.NotificationWithEntity
453 Recents []RecentItem
454 FollowingOnly bool
455 RecentBlogPosts []BlogPost
456 // ShowNewsletter controls whether the newsletter widget/CTA is rendered.
457 // For logged-in users it reflects their newsletter_preferences row; for
458 // anonymous visitors it is always true (dismissal falls back to
459 // localStorage on the client).
460 ShowNewsletter bool
461 CanFocus bool
462}
463
464func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
465 return p.execute("timeline/timeline", w, params)
466}
467
468type GoodFirstIssuesParams struct {
469 BaseParams
470 Issues []models.Issue
471 RepoGroups []*models.RepoGroup
472 LabelDefs map[string]*models.LabelDefinition
473 GfiLabel *models.LabelDefinition
474 Page pagination.Page
475}
476
477func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
478 return p.execute("goodfirstissues/index", w, params)
479}
480
481type UserProfileSettingsParams struct {
482 BaseParams
483 Tab string
484 PunchcardPreference models.PunchcardPreference
485 IsTnglSh bool
486 IsDeactivated bool
487 HandleOpen bool
488}
489
490func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
491 params.Tab = "profile"
492 return p.execute("user/settings/profile", w, params)
493}
494
495type GroupedNotifications struct {
496 Today []*models.NotificationWithEntity
497 ThisWeek []*models.NotificationWithEntity
498 Older []*models.NotificationWithEntity
499}
500
501func GroupNotificationsByDate(notifs []*models.NotificationWithEntity) GroupedNotifications {
502 now := time.Now()
503 todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
504 weekStart := todayStart.AddDate(0, 0, -6)
505
506 var g GroupedNotifications
507 for _, n := range notifs {
508 switch {
509 case !n.Created.Before(todayStart):
510 g.Today = append(g.Today, n)
511 case !n.Created.Before(weekStart):
512 g.ThisWeek = append(g.ThisWeek, n)
513 default:
514 g.Older = append(g.Older, n)
515 }
516 }
517 return g
518}
519
520type NotificationsParams struct {
521 BaseParams
522 WorkGroups GroupedNotifications
523 SocialGroups GroupedNotifications
524 MobileGroups GroupedNotifications
525 WorkUnreadCount int64
526 SocialUnreadCount int64
527 Page pagination.Page
528 Total int
529 ReadFilter string // "inbox" or "unread"
530 CategoryFilter string // "all", "work", "social"
531 CanFocus bool
532}
533
534func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
535 return p.execute("notifications/list", w, params)
536}
537
538func (p *Pages) NotificationItem(w io.Writer, notif *models.NotificationWithEntity) error {
539 return p.executePlain("notifications/fragments/item", w, notif)
540}
541
542type NotificationCountParams struct {
543 Count int64
544}
545
546func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
547 return p.executePlain("notifications/fragments/count", w, params)
548}
549
550type NotificationPreviewParams struct {
551 BaseParams
552 Notifications []*models.NotificationWithEntity
553 ReadFilter string
554 CategoryFilter string
555 CanFocus bool
556}
557
558func (p *Pages) NotificationPreview(w io.Writer, params NotificationPreviewParams) error {
559 return p.executePlain("notifications/fragments/preview", w, params)
560}
561
562type UserKeysSettingsParams struct {
563 BaseParams
564 PubKeys []models.PublicKey
565 Tab string
566}
567
568func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
569 params.Tab = "keys"
570 return p.execute("user/settings/keys", w, params)
571}
572
573type UserEmailsSettingsParams struct {
574 BaseParams
575 Emails []models.Email
576 Tab string
577}
578
579func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
580 params.Tab = "emails"
581 return p.execute("user/settings/emails", w, params)
582}
583
584type UserNotificationSettingsParams struct {
585 BaseParams
586 Preferences *models.NotificationPreferences
587 Tab string
588}
589
590func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
591 params.Tab = "notifications"
592 return p.execute("user/settings/notifications", w, params)
593}
594
595type UserSiteSettingsParams struct {
596 BaseParams
597 Claim *models.DomainClaim
598 SitesDomain string
599 IsTnglHandle bool
600 Tab string
601}
602
603func (p *Pages) UserSiteSettings(w io.Writer, params UserSiteSettingsParams) error {
604 params.Tab = "sites"
605 return p.execute("user/settings/sites", w, params)
606}
607
608type UpgradeBannerParams struct {
609 Registrations []models.Registration
610 Spindles []models.Spindle
611}
612
613func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
614 return p.executePlain("banner", w, params)
615}
616
617type NewsletterResponseParams struct {
618 // Id identifies the calling form instance; the response span's id will
619 // be "newsletter-msg-<Id>" so it round-trips with the form's hx-target.
620 Id string
621 // Error, when non-empty, switches the template to the error variant.
622 Error string
623}
624
625func (p *Pages) NewsletterResponse(w io.Writer, params NewsletterResponseParams) error {
626 return p.executePlain("timeline/fragments/newsletterResponse", w, params)
627}
628
629type KnotsParams struct {
630 BaseParams
631 Knots []KnotListingParams
632 Tab string
633}
634
635func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
636 params.Tab = "knots"
637 return p.execute("knots/index", w, params)
638}
639
640type KnotParams struct {
641 BaseParams
642 Registration *models.Registration
643 Members []string
644 Repos map[string][]models.Repo
645 IsOwner bool
646 RepoCount int
647 Tab string
648}
649
650func (p *Pages) Knot(w io.Writer, params KnotParams) error {
651 return p.execute("knots/dashboard", w, params)
652}
653
654type KnotListingParams struct {
655 *models.Registration
656 RepoCount int
657}
658
659func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
660 return p.executePlain("knots/fragments/knotListing", w, params)
661}
662
663type SpindlesParams struct {
664 BaseParams
665 Spindles []models.Spindle
666 Tab string
667}
668
669func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
670 params.Tab = "spindles"
671 return p.execute("spindles/index", w, params)
672}
673
674type SpindleListingParams struct {
675 models.Spindle
676 Tab string
677}
678
679func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
680 return p.executePlain("spindles/fragments/spindleListing", w, params)
681}
682
683type SpindleDashboardParams struct {
684 BaseParams
685 Spindle models.Spindle
686 Members []string
687 Repos map[string][]models.Repo
688 Tab string
689}
690
691func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
692 return p.execute("spindles/dashboard", w, params)
693}
694
695type NewRepoParams struct {
696 BaseParams
697 Knots []string
698}
699
700func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
701 return p.execute("repo/new", w, params)
702}
703
704type ForkRepoParams struct {
705 BaseParams
706 Knots []string
707 RepoInfo repoinfo.RepoInfo
708}
709
710func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
711 return p.execute("repo/fork", w, params)
712}
713
714type ProfileCard struct {
715 UserDid string
716 HasProfile bool
717 IsTangledUser bool
718 FollowStatus models.FollowStatus
719 VouchRelationship *models.VouchRelationship
720 Punchcard *models.Punchcard
721 Profile *models.Profile
722 Stats ProfileStats
723 Active string
724 ProfileScript string
725}
726
727type ProfileStats struct {
728 RepoCount int64
729 StarredCount int64
730 StringCount int64
731 FollowersCount int64
732 FollowingCount int64
733}
734
735func (p *ProfileCard) GetTabs() [][]any {
736 tabs := [][]any{
737 {"overview", "overview", "square-chart-gantt", nil},
738 {"repos", "repos", "book-marked", p.Stats.RepoCount},
739 {"starred", "starred", "star", p.Stats.StarredCount},
740 {"strings", "strings", "line-squiggle", p.Stats.StringCount},
741 {"vouches", "vouches", "shield", nil},
742 }
743
744 return tabs
745}
746
747type ProfileOverviewParams struct {
748 BaseParams
749 Repos []models.Repo
750 CollaboratingRepos []models.Repo
751 ProfileTimeline *models.ProfileTimeline
752 Card *ProfileCard
753 Active string
754 ShowPunchcard bool
755}
756
757func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
758 params.Active = "overview"
759 return p.executeProfile("user/overview", w, params)
760}
761
762type ProfileReposParams struct {
763 BaseParams
764 Repos []models.Repo
765 StarStatuses map[string]bool
766 Card *ProfileCard
767 Active string
768 Page pagination.Page
769 RepoCount int
770 FilterQuery string
771}
772
773func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
774 params.Active = "repos"
775 return p.executeProfile("user/repos", w, params)
776}
777
778type ProfileStarredParams struct {
779 BaseParams
780 Repos []models.Repo
781 Card *ProfileCard
782 Page pagination.Page
783 Total int
784 Active string
785}
786
787func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
788 params.Active = "starred"
789 return p.executeProfile("user/starred", w, params)
790}
791
792type ProfileStringsParams struct {
793 BaseParams
794 Strings []models.String
795 Card *ProfileCard
796 Active string
797}
798
799func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
800 params.Active = "strings"
801 return p.executeProfile("user/strings", w, params)
802}
803
804type ProfileVouchesParams struct {
805 BaseParams
806 Vouches []models.Vouch
807 Suggestions []models.VouchSuggestion
808 Card *ProfileCard
809 Page pagination.Page
810 VouchCount int
811 Active string
812 EvidencePulls map[syntax.ATURI]*models.Pull
813 EvidenceIssues map[syntax.ATURI]*models.Issue
814}
815
816func (p *Pages) ProfileVouches(w io.Writer, params ProfileVouchesParams) error {
817 params.Active = "vouches"
818 return p.executeProfile("user/vouches", w, params)
819}
820
821type FollowCard struct {
822 UserDid string
823 BaseParams
824 FollowStatus models.FollowStatus
825 FollowersCount int64
826 FollowingCount int64
827 Profile *models.Profile
828}
829
830type ProfileFollowersParams struct {
831 BaseParams
832 Followers []FollowCard
833 Card *ProfileCard
834 Active string
835}
836
837func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
838 params.Active = "overview"
839 return p.executeProfile("user/followers", w, params)
840}
841
842type ProfileFollowingParams struct {
843 BaseParams
844 Following []FollowCard
845 Card *ProfileCard
846 Active string
847}
848
849func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error {
850 params.Active = "overview"
851 return p.executeProfile("user/following", w, params)
852}
853
854type FollowFragmentParams struct {
855 UserDid string
856 FollowStatus models.FollowStatus
857 FollowersCount int64
858}
859
860func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
861 return p.executePlain("user/fragments/follow-oob", w, params)
862}
863
864type ProfilePopoverParams struct {
865 BaseParams
866 UserDid string
867 Profile *models.Profile
868 FollowStatus models.FollowStatus
869 VouchRelationship *models.VouchRelationship
870 Stats ProfilePopoverStats
871}
872
873type ProfilePopoverStats struct {
874 FollowersCount int64
875 FollowingCount int64
876}
877
878func (p *Pages) ProfilePopoverFragment(w io.Writer, params ProfilePopoverParams) error {
879 return p.executePlain("user/fragments/profilePopover", w, params)
880}
881
882type EditBioParams struct {
883 BaseParams
884 Profile *models.Profile
885 AlsoKnownAs []string
886 // Action optionally overrides the form's hx-post target. Defaults to
887 // /profile/bio when empty. Used by the onboarding flow to save + advance.
888 Action string
889}
890
891func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
892 return p.executePlain("user/fragments/editBio", w, params)
893}
894
895type EditPinsParams struct {
896 BaseParams
897 Profile *models.Profile
898 AllRepos []PinnedRepo
899}
900
901type PinnedRepo struct {
902 IsPinned bool
903 models.Repo
904}
905
906func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
907 return p.executePlain("user/fragments/editPins", w, params)
908}
909
910type StarBtnFragmentParams struct {
911 IsStarred bool
912 SubjectAt syntax.ATURI
913 StarCount int
914 RepoName string
915 HxSwapOob bool
916}
917
918func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
919 params.HxSwapOob = true
920 return p.executePlain("fragments/starBtn", w, params)
921}
922
923type OnboardingParams struct {
924 BaseParams
925 Step int
926
927 EditBio EditBioParams
928
929 PubKeys []models.PublicKey
930
931 People []FollowCard
932 TrendingRepos []models.Repo
933 StarStatuses map[string]bool
934}
935
936// KeyFragment renders a single SSH key row (used to append a newly added key
937// to the list without a full reload).
938func (p *Pages) KeyFragment(w io.Writer, key models.PublicKey) error {
939 return p.executePlain("user/settings/fragments/keyListing", w, key)
940}
941
942func (p *Pages) Onboarding(w io.Writer, params OnboardingParams) error {
943 return p.executeOnboarding("onboarding/welcome", w, params)
944}
945
946type RepoIndexParams struct {
947 BaseParams
948 RepoInfo repoinfo.RepoInfo
949 Active string
950 TagMap map[string][]string
951 CommitsTrunc []types.Commit
952 TagsTrunc []*types.TagReference
953 BranchesTrunc []types.Branch
954 // ForkInfo *types.ForkInfo
955 HTMLReadme template.HTML
956 Raw bool
957 EmailToDid map[string]string
958 VerifiedCommits commitverify.VerifiedCommits
959 Languages []types.RepoLanguageDetails
960 Pipelines map[string]types.Pipeline
961 NeedsKnotUpgrade bool
962 KnotUnreachable bool
963 types.RepoIndexResponse
964}
965
966func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
967 params.Active = "overview"
968 if params.IsEmpty {
969 return p.executeRepo("repo/empty", w, params)
970 }
971
972 if params.NeedsKnotUpgrade {
973 return p.executeRepo("repo/needsUpgrade", w, params)
974 }
975
976 if params.KnotUnreachable {
977 return p.executeRepo("repo/knotUnreachable", w, params)
978 }
979
980 rctx := p.rctx.Clone()
981 rctx.RepoInfo = params.RepoInfo
982 rctx.RepoInfo.Ref = params.Ref
983 rctx.RendererType = markup.RendererTypeRepoMarkdown
984
985 if params.ReadmeFileName != "" {
986 switch markup.GetFormat(params.ReadmeFileName) {
987 case markup.FormatMarkdown:
988 params.Raw = false
989 htmlString := rctx.RenderMarkdown(params.Readme)
990 sanitized := sanitizer.SanitizeDefault(htmlString)
991 params.HTMLReadme = template.HTML(sanitized)
992 default:
993 params.Raw = true
994 }
995 }
996
997 return p.executeRepo("repo/index", w, params)
998}
999
1000type RepoSearchParams struct {
1001 BaseParams
1002 RepoInfo repoinfo.RepoInfo
1003 Active string
1004 FilterQuery string
1005}
1006
1007func (p *Pages) RepoSearchPage(w io.Writer, params RepoSearchParams) error {
1008 params.Active = "overview"
1009 return p.executeRepo("repo/search", w, params)
1010}
1011
1012type RepoSearchResultsFragmentParams struct {
1013 Query string
1014 Results []SearchResult
1015 ErrorMsg string
1016}
1017
1018func (p *Pages) RepoSearchResultsFragment(w io.Writer, params RepoSearchResultsFragmentParams) error {
1019 return p.executePlain("repo/fragments/searchResults", w, params)
1020}
1021
1022type RepoLogParams struct {
1023 BaseParams
1024 RepoInfo repoinfo.RepoInfo
1025 TagMap map[string][]string
1026 Active string
1027 EmailToDid map[string]string
1028 VerifiedCommits commitverify.VerifiedCommits
1029 Pipelines map[string]types.Pipeline
1030
1031 types.RepoLogResponse
1032}
1033
1034func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
1035 params.Active = "overview"
1036 return p.executeRepo("repo/log", w, params)
1037}
1038
1039type RepoCommitParams struct {
1040 BaseParams
1041 RepoInfo repoinfo.RepoInfo
1042 Active string
1043 EmailToDid map[string]string
1044 Pipeline *types.Pipeline
1045 DiffOpts types.DiffOpts
1046
1047 // singular because it's always going to be just one
1048 VerifiedCommit commitverify.VerifiedCommits
1049
1050 types.RepoCommitResponse
1051}
1052
1053func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
1054 params.Active = "overview"
1055 return p.executeRepo("repo/commit", w, params)
1056}
1057
1058type RepoTreeParams struct {
1059 BaseParams
1060 RepoInfo repoinfo.RepoInfo
1061 Active string
1062 BreadCrumbs [][]string
1063 Path string
1064 Raw bool
1065 HTMLReadme template.HTML
1066 EmailToDid map[string]string
1067 LastCommitInfo *types.LastCommitInfo
1068 Ref string
1069 Parent string
1070 DotDot string
1071 Files []types.NiceTree
1072 ReadmeFileName string
1073 Readme string
1074}
1075
1076type RepoTreeStats struct {
1077 NumFolders uint64
1078 NumFiles uint64
1079}
1080
1081func (r RepoTreeParams) TreeStats() RepoTreeStats {
1082 numFolders, numFiles := 0, 0
1083 for _, f := range r.Files {
1084 if !f.IsFile() {
1085 numFolders += 1
1086 } else if f.IsFile() {
1087 numFiles += 1
1088 }
1089 }
1090
1091 return RepoTreeStats{
1092 NumFolders: uint64(numFolders),
1093 NumFiles: uint64(numFiles),
1094 }
1095}
1096
1097func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
1098 params.Active = "overview"
1099
1100 rctx := p.rctx.Clone()
1101 rctx.RepoInfo = params.RepoInfo
1102 rctx.RepoInfo.Ref = params.Ref
1103 rctx.RendererType = markup.RendererTypeRepoMarkdown
1104
1105 if params.ReadmeFileName != "" {
1106 switch markup.GetFormat(params.ReadmeFileName) {
1107 case markup.FormatMarkdown:
1108 params.Raw = false
1109 htmlString := rctx.RenderMarkdown(params.Readme)
1110 sanitized := sanitizer.SanitizeDefault(htmlString)
1111 params.HTMLReadme = template.HTML(sanitized)
1112 default:
1113 params.Raw = true
1114 }
1115 }
1116
1117 return p.executeRepo("repo/tree", w, params)
1118}
1119
1120type RepoBranchesParams struct {
1121 BaseParams
1122 RepoInfo repoinfo.RepoInfo
1123 Active string
1124 types.RepoBranchesResponse
1125}
1126
1127func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
1128 params.Active = "overview"
1129 return p.executeRepo("repo/branches", w, params)
1130}
1131
1132type RepoTagsParams struct {
1133 BaseParams
1134 RepoInfo repoinfo.RepoInfo
1135 Active string
1136 types.RepoTagsResponse
1137 ArtifactMap map[plumbing.Hash][]models.Artifact
1138 DanglingArtifacts []models.Artifact
1139}
1140
1141func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
1142 params.Active = "overview"
1143 return p.executeRepo("repo/tags", w, params)
1144}
1145
1146type RepoTagParams struct {
1147 BaseParams
1148 RepoInfo repoinfo.RepoInfo
1149 Active string
1150 types.RepoTagResponse
1151 ArtifactMap map[plumbing.Hash][]models.Artifact
1152 DanglingArtifacts []models.Artifact
1153}
1154
1155func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error {
1156 params.Active = "overview"
1157 return p.executeRepo("repo/tag", w, params)
1158}
1159
1160type RepoArtifactParams struct {
1161 BaseParams
1162 RepoInfo repoinfo.RepoInfo
1163 Artifact models.Artifact
1164}
1165
1166func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
1167 return p.executePlain("repo/fragments/artifact", w, params)
1168}
1169
1170type RepoBlobParams struct {
1171 BaseParams
1172 RepoInfo repoinfo.RepoInfo
1173 Active string // always "overview"
1174 BreadCrumbs [][]string
1175 BlobView models.BlobView // TODO: expose this struct
1176 ShowRendered bool
1177 EmailToDid map[string]string
1178 LastCommitInfo *types.LastCommitInfo
1179 Ref string
1180 Path string
1181 Language string
1182}
1183
1184func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
1185 params.Active = "overview"
1186 return p.executeRepo("repo/blob", w, params)
1187}
1188
1189type Collaborator struct {
1190 Did string
1191 Role string
1192}
1193
1194type RepoSettingsParams struct {
1195 BaseParams
1196 RepoInfo repoinfo.RepoInfo
1197 Collaborators []Collaborator
1198 Active string
1199 Branches []types.Branch
1200 Spindles []string
1201 CurrentSpindle string
1202 Secrets []*tangled.RepoListSecrets_Secret
1203
1204 // TODO: use repoinfo.roles
1205 IsCollaboratorInviteAllowed bool
1206}
1207
1208func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
1209 params.Active = "settings"
1210 return p.executeRepo("repo/settings", w, params)
1211}
1212
1213type RepoGeneralSettingsParams struct {
1214 BaseParams
1215 RepoInfo repoinfo.RepoInfo
1216 Labels []models.LabelDefinition
1217 DefaultLabels []models.LabelDefinition
1218 SubscribedLabels map[string]struct{}
1219 ShouldSubscribeAll bool
1220 Active string
1221 Tab string
1222 Branches []types.Branch
1223}
1224
1225func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
1226 params.Active = "settings"
1227 params.Tab = "general"
1228 return p.executeRepo("repo/settings/general", w, params)
1229}
1230
1231type RepoAccessSettingsParams struct {
1232 BaseParams
1233 RepoInfo repoinfo.RepoInfo
1234 Active string
1235 Tab string
1236 Collaborators []Collaborator
1237 CanRemoveCollaborator bool
1238}
1239
1240func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
1241 params.Active = "settings"
1242 params.Tab = "access"
1243 return p.executeRepo("repo/settings/access", w, params)
1244}
1245
1246type RepoPipelineSettingsParams struct {
1247 BaseParams
1248 RepoInfo repoinfo.RepoInfo
1249 Active string
1250 Tab string
1251 Spindles []string
1252 CurrentSpindle string
1253 Secrets []map[string]any
1254}
1255
1256func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
1257 params.Active = "settings"
1258 params.Tab = "pipelines"
1259 return p.executeRepo("repo/settings/pipelines", w, params)
1260}
1261
1262type RepoWebhooksSettingsParams struct {
1263 BaseParams
1264 RepoInfo repoinfo.RepoInfo
1265 Active string
1266 Tab string
1267 Webhooks []models.Webhook
1268 WebhookDeliveries map[int64][]models.WebhookDelivery
1269}
1270
1271func (p *Pages) RepoWebhooksSettings(w io.Writer, params RepoWebhooksSettingsParams) error {
1272 params.Active = "settings"
1273 params.Tab = "hooks"
1274 return p.executeRepo("repo/settings/hooks", w, params)
1275}
1276
1277type WebhookDeliveriesListParams struct {
1278 BaseParams
1279 RepoInfo repoinfo.RepoInfo
1280 Webhook *models.Webhook
1281 Deliveries []models.WebhookDelivery
1282}
1283
1284func (p *Pages) WebhookDeliveriesList(w io.Writer, params WebhookDeliveriesListParams) error {
1285 tpl, err := p.parse("repo/settings/fragments/webhookDeliveries")
1286 if err != nil {
1287 return err
1288 }
1289 return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params)
1290}
1291
1292type RepoSiteSettingsParams struct {
1293 BaseParams
1294 RepoInfo repoinfo.RepoInfo
1295 Active string
1296 Tab string
1297 Branches []types.Branch
1298 SiteConfig *models.RepoSite
1299 OwnerClaim *models.DomainClaim
1300 Deploys []models.SiteDeploy
1301 IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or ""
1302}
1303
1304func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error {
1305 params.Active = "settings"
1306 params.Tab = "sites"
1307 return p.executeRepo("repo/settings/sites", w, params)
1308}
1309
1310type RepoIssuesParams struct {
1311 BaseParams
1312 RepoInfo repoinfo.RepoInfo
1313 Active string
1314 Issues []models.Issue
1315 IssueCount int
1316 LabelDefs map[string]*models.LabelDefinition
1317 Page pagination.Page
1318 FilterState string
1319 FilterQuery string
1320 BaseFilterQuery string
1321 VouchRelationships map[syntax.DID]*models.VouchRelationship
1322}
1323
1324func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
1325 params.Active = "issues"
1326 return p.executeRepo("repo/issues/issues", w, params)
1327}
1328
1329type RepoSingleIssueParams struct {
1330 BaseParams
1331 RepoInfo repoinfo.RepoInfo
1332 Active string
1333 Issue *models.Issue
1334 CommentList []models.CommentListItem
1335 Backlinks []models.RichReferenceLink
1336 LabelDefs map[string]*models.LabelDefinition
1337
1338 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1339 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1340 VouchRelationships map[syntax.DID]*models.VouchRelationship
1341}
1342
1343func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
1344 params.Active = "issues"
1345 return p.executeRepo("repo/issues/issue", w, params)
1346}
1347
1348type EditIssueParams struct {
1349 BaseParams
1350 RepoInfo repoinfo.RepoInfo
1351 Issue *models.Issue
1352 Action string
1353}
1354
1355func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
1356 params.Action = "edit"
1357 return p.executePlain("repo/issues/fragments/putIssue", w, params)
1358}
1359
1360type ThreadReactionFragmentParams struct {
1361 Kind models.ReactionKind
1362 Count int
1363 Users []string
1364 IsReacted bool
1365 CommentRkey string
1366 SubjectUri string
1367}
1368
1369func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
1370 return p.executePlain("repo/fragments/reaction", w, params)
1371}
1372
1373type RepoNewIssueParams struct {
1374 BaseParams
1375 RepoInfo repoinfo.RepoInfo
1376 Issue *models.Issue // existing issue if any -- passed when editing
1377 Active string
1378 Action string
1379}
1380
1381func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
1382 params.Active = "issues"
1383 params.Action = "create"
1384 return p.executeRepo("repo/issues/new", w, params)
1385}
1386
1387type StackedDiff struct {
1388 Diff *types.NiceDiff
1389 Opts types.DiffOpts
1390}
1391
1392type RepoNewPullParams struct {
1393 BaseParams
1394 RepoInfo repoinfo.RepoInfo
1395 Branches []types.Branch
1396 SourceBranches []types.Branch
1397 ForkBranches []types.Branch
1398 Forks []models.Repo
1399 Source Source
1400 SourceBranch string
1401 TargetBranch string
1402 Fork string
1403 Patch string
1404 Title string
1405 Body string
1406 TitleDirty bool
1407 BodyDirty bool
1408 IsStacked bool
1409 Comparison *types.RepoFormatPatchResponse
1410 Diff *types.NiceDiff
1411 DiffOpts types.DiffOpts
1412 StackedDiffs []StackedDiff
1413 MergeCheck *types.MergeCheckResponse
1414 StackTitles map[string]string
1415 StackBodies map[string]string
1416 PrefillError string
1417 Active string
1418 LabelDefs map[string]*models.LabelDefinition
1419 LabelState models.LabelState
1420 StackLabelStates map[string]models.LabelState
1421}
1422
1423func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
1424 params.Active = "pulls"
1425 return p.executeRepo("repo/pulls/new", w, params)
1426}
1427
1428func (p *Pages) PullComposeHostFragment(w io.Writer, params RepoNewPullParams) error {
1429 return p.executePlain("repo/pulls/fragments/pullComposeHost", w, params)
1430}
1431
1432func (p *Pages) MarkdownPreviewFragment(w io.Writer, body string) error {
1433 return p.executePlain("fragments/markdownPreview", w, body)
1434}
1435
1436type RepoPullsParams struct {
1437 BaseParams
1438 RepoInfo repoinfo.RepoInfo
1439 Pulls []*models.Pull
1440 Active string
1441 FilterState string
1442 FilterQuery string
1443 BaseFilterQuery string
1444 Stacks []models.Stack
1445 Pipelines map[string]types.Pipeline
1446 LabelDefs map[string]*models.LabelDefinition
1447 Page pagination.Page
1448 PullCount int
1449 VouchRelationships map[syntax.DID]*models.VouchRelationship
1450}
1451
1452func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
1453 params.Active = "pulls"
1454 return p.executeRepo("repo/pulls/pulls", w, params)
1455}
1456
1457type ResubmitResult uint64
1458
1459const (
1460 ShouldResubmit ResubmitResult = iota
1461 ShouldNotResubmit
1462 Unknown
1463)
1464
1465func (r ResubmitResult) Yes() bool {
1466 return r == ShouldResubmit
1467}
1468func (r ResubmitResult) No() bool {
1469 return r == ShouldNotResubmit
1470}
1471func (r ResubmitResult) Unknown() bool {
1472 return r == Unknown
1473}
1474
1475type RepoSinglePullParams struct {
1476 BaseParams
1477 RepoInfo repoinfo.RepoInfo
1478 Active string
1479 Pull *models.Pull
1480 Stack models.Stack
1481 Backlinks []models.RichReferenceLink
1482 BranchDeleteStatus *models.BranchDeleteStatus
1483 MergeCheck types.MergeCheckResponse
1484 ResubmitCheck ResubmitResult
1485 Pipelines map[string]types.Pipeline
1486 Diff types.DiffRenderer
1487 DiffOpts types.DiffOpts
1488 ActiveRound int
1489 IsInterdiff bool
1490
1491 // WorkflowsChanged and ChangedWorkflowFiles describe whether the latest
1492 // round's patch touches .tangled/workflows/, for warning maintainers
1493 // before they manually trigger CI on a fork-based pull request.
1494 WorkflowsChanged bool
1495 ChangedWorkflowFiles []string
1496
1497 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1498 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1499
1500 LabelDefs map[string]*models.LabelDefinition
1501 VouchRelationships map[syntax.DID]*models.VouchRelationship
1502 VouchSkips map[syntax.DID]bool
1503}
1504
1505func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
1506 params.Active = "pulls"
1507 return p.executeRepo("repo/pulls/pull", w, params)
1508}
1509
1510type PullResubmitParams struct {
1511 BaseParams
1512 RepoInfo repoinfo.RepoInfo
1513 Pull *models.Pull
1514 SubmissionId int
1515}
1516
1517func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
1518 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
1519}
1520
1521type PullActionsParams struct {
1522 BaseParams
1523 RepoInfo repoinfo.RepoInfo
1524 Pull *models.Pull
1525 RoundNumber int
1526 MergeCheck types.MergeCheckResponse
1527 ResubmitCheck ResubmitResult
1528 BranchDeleteStatus *models.BranchDeleteStatus
1529 Stack models.Stack
1530
1531 // renders buttons in a pre-check state and attaches the hx-trigger="load"
1532 // that fetches the real, checked fragment
1533 Loading bool
1534}
1535
1536func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
1537 return p.executePlain("repo/pulls/fragments/pullActions", w, params)
1538}
1539
1540type PullNewCommentParams struct {
1541 BaseParams
1542 RepoInfo repoinfo.RepoInfo
1543 Pull *models.Pull
1544 RoundNumber int
1545}
1546
1547func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
1548 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
1549}
1550
1551type RepoCompareParams struct {
1552 BaseParams
1553 RepoInfo repoinfo.RepoInfo
1554 Forks []models.Repo
1555 Branches []types.Branch
1556 Tags []*types.TagReference
1557 Base string
1558 Head string
1559 Diff *types.NiceDiff
1560 DiffOpts types.DiffOpts
1561
1562 Active string
1563}
1564
1565func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error {
1566 params.Active = "overview"
1567 return p.executeRepo("repo/compare/compare", w, params)
1568}
1569
1570type RepoCompareNewParams struct {
1571 BaseParams
1572 RepoInfo repoinfo.RepoInfo
1573 Forks []models.Repo
1574 Branches []types.Branch
1575 Tags []*types.TagReference
1576 Base string
1577 Head string
1578
1579 Active string
1580}
1581
1582func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error {
1583 params.Active = "overview"
1584 return p.executeRepo("repo/compare/new", w, params)
1585}
1586
1587type RepoCompareAllowPullParams struct {
1588 BaseParams
1589 RepoInfo repoinfo.RepoInfo
1590 Base string
1591 Head string
1592}
1593
1594func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error {
1595 return p.executePlain("repo/fragments/compareAllowPull", w, params)
1596}
1597
1598type RepoCompareDiffFragmentParams struct {
1599 Diff types.NiceDiff
1600 DiffOpts types.DiffOpts
1601}
1602
1603func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
1604 return p.executePlain("repo/fragments/diff", w, []any{¶ms.Diff, ¶ms.DiffOpts})
1605}
1606
1607type LabelPanelParams struct {
1608 BaseParams
1609 RepoInfo repoinfo.RepoInfo
1610 Defs map[string]*models.LabelDefinition
1611 Subject string
1612 State models.LabelState
1613}
1614
1615func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
1616 return p.executePlain("repo/fragments/labelPanel", w, params)
1617}
1618
1619type EditLabelPanelParams struct {
1620 BaseParams
1621 RepoInfo repoinfo.RepoInfo
1622 Defs map[string]*models.LabelDefinition
1623 Subject string
1624 State models.LabelState
1625 Prefix string
1626}
1627
1628func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
1629 return p.executePlain("repo/fragments/editLabelPanel", w, params)
1630}
1631
1632type RepoStarsParams struct {
1633 BaseParams
1634 RepoInfo repoinfo.RepoInfo
1635 Active string
1636 Starrers []models.Star
1637 Page pagination.Page
1638 TotalCount int
1639}
1640
1641func (p *Pages) RepoStars(w io.Writer, params RepoStarsParams) error {
1642 params.Active = "overview"
1643 return p.executeRepo("repo/stars", w, params)
1644}
1645
1646type RepoForksParams struct {
1647 BaseParams
1648 RepoInfo repoinfo.RepoInfo
1649 Active string
1650 Forks []models.Repo
1651 Page pagination.Page
1652 TotalCount int
1653}
1654
1655func (p *Pages) RepoForks(w io.Writer, params RepoForksParams) error {
1656 params.Active = "overview"
1657 return p.executeRepo("repo/forks", w, params)
1658}
1659
1660type PipelinesParams struct {
1661 BaseParams
1662 RepoInfo repoinfo.RepoInfo
1663 Pipelines []types.Pipeline
1664 Active string
1665 FilterKind string
1666 Total int64
1667}
1668
1669func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
1670 params.Active = "pipelines"
1671 return p.executeRepo("repo/pipelines/pipelines", w, params)
1672}
1673
1674type LogBlockParams struct {
1675 Id int
1676 Name string
1677 Command string
1678 Collapsed bool
1679 StartTime time.Time
1680}
1681
1682func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1683 return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1684}
1685
1686type LogBlockEndParams struct {
1687 Id int
1688 StartTime time.Time
1689 EndTime time.Time
1690}
1691
1692func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1693 return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1694}
1695
1696type LogLineParams struct {
1697 Id int
1698 Content template.HTML
1699}
1700
1701func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
1702 return p.executePlain("repo/pipelines/fragments/logLine", w, params)
1703}
1704
1705type WorkflowSymbolOOBParams struct {
1706 Name string
1707 Statuses models.WorkflowStatus
1708}
1709
1710func (p *Pages) WorkflowSymbolOOB(w io.Writer, params WorkflowSymbolOOBParams) error {
1711 return p.executePlain("repo/pipelines/fragments/workflowSymbolOOB", w, params)
1712}
1713
1714type WorkflowParams struct {
1715 BaseParams
1716 RepoInfo repoinfo.RepoInfo
1717 Pipeline types.Pipeline
1718 Workflow string
1719 LogUrl string
1720 Active string
1721}
1722
1723func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1724 params.Active = "pipelines"
1725 return p.executeRepo("repo/pipelines/workflow", w, params)
1726}
1727
1728type PutStringParams struct {
1729 BaseParams
1730 Action string
1731
1732 // this is supplied in the case of editing an existing string
1733 String models.String
1734}
1735
1736func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1737 return p.execute("strings/put", w, params)
1738}
1739
1740type StringsDashboardParams struct {
1741 BaseParams
1742 Card ProfileCard
1743 Strings []models.String
1744}
1745
1746func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1747 return p.execute("strings/dashboard", w, params)
1748}
1749
1750type StringTimelineParams struct {
1751 BaseParams
1752 Strings []models.String
1753}
1754
1755func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
1756 return p.execute("strings/timeline", w, params)
1757}
1758
1759type SingleStringParams struct {
1760 BaseParams
1761 ShowRendered bool
1762 RenderToggle bool
1763 RenderedContents template.HTML
1764 String *models.String
1765 Stats models.StringStats
1766 IsStarred bool
1767 StarCount int
1768 Owner identity.Identity
1769 CommentList []models.CommentListItem
1770
1771 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1772 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1773 VouchRelationships map[syntax.DID]*models.VouchRelationship
1774}
1775
1776func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1777 return p.execute("strings/string", w, params)
1778}
1779
1780type SearchReposParams struct {
1781 BaseParams
1782 FilterType string // "repo" | "code"
1783 Repos []SearchResult
1784 Page pagination.Page
1785 ResultCount int
1786 FilterQuery string
1787 SortParam string
1788 TimeTaken time.Duration
1789 DocCount int64
1790 ErrorMsg string
1791}
1792
1793func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error {
1794 params.FilterType = "repo"
1795 return p.execute("search/search", w, params)
1796}
1797
1798type SearchQuickParams struct {
1799 Repos []models.Repo
1800 Query string
1801 Total int
1802}
1803
1804func (p *Pages) SearchQuick(w io.Writer, params SearchQuickParams) error {
1805 return p.executePlain("search/fragments/quick", w, params)
1806}
1807
1808func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error {
1809 tpl, err := p.parse("search/fragments/quick")
1810 if err != nil {
1811 return err
1812 }
1813 return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params)
1814}
1815
1816type SearchResult struct {
1817 RepoDID syntax.DID
1818 Repo *models.Repo
1819 FilePath string
1820 Branches []string
1821 Commit string
1822 Language string
1823
1824 File *CodeSearchResult_File // filename match
1825 Chunks CodeSearchResult_Chunks // content matches
1826}
1827
1828// CodeSearchResult_Chunk is a content match with its lines pre-rendered.
1829type CodeSearchResult_Chunk struct {
1830 Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges
1831 MatchCount int // number of match ranges in this chunk
1832}
1833
1834type CodeSearchResult_Chunks []CodeSearchResult_Chunk
1835
1836func (cs CodeSearchResult_Chunks) MatchCount() int {
1837 count := 0
1838 for _, c := range cs {
1839 count += c.MatchCount
1840 }
1841 return count
1842}
1843
1844type CodeSearchResult_File struct {
1845 NameSpans []ChunkSpan // precomputed from FilePath/Ranges
1846}
1847
1848type ChunkSpan struct {
1849 Text string
1850 Match bool
1851}
1852
1853type ChunkLine struct {
1854 Num int
1855 Spans []ChunkSpan
1856 Highlight bool
1857}
1858
1859// ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each
1860// line into matched/unmatched spans using ranges. startLine is the 1-based line
1861// number of the first line.
1862func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine {
1863 if startLine < 1 {
1864 startLine = 1
1865 }
1866 // trim a single trailing newline so we don't emit a spurious empty line
1867 content = strings.TrimSuffix(content, "\n")
1868 lines := strings.Split(content, "\n")
1869 out := make([]ChunkLine, len(lines))
1870 for i, text := range lines {
1871 num := startLine + i
1872 runes := []rune(text)
1873
1874 // collect matched rune intervals [c0,c1) for this line
1875 var intervals [][2]int
1876 for _, rg := range ranges {
1877 if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) {
1878 continue
1879 }
1880 c0, c1 := 0, len(runes)
1881 if num == int(rg.Start.LineNumber) {
1882 c0 = int(rg.Start.Column) - 1
1883 }
1884 if num == int(rg.End.LineNumber) {
1885 c1 = int(rg.End.Column) - 1
1886 }
1887 c0 = max(0, min(c0, len(runes)))
1888 c1 = max(0, min(c1, len(runes)))
1889 if c0 < c1 {
1890 intervals = append(intervals, [2]int{c0, c1})
1891 }
1892 }
1893 intervals = mergeIntervals(intervals)
1894
1895 out[i] = ChunkLine{
1896 Num: num,
1897 Spans: spanRunes(runes, intervals),
1898 Highlight: len(intervals) > 0,
1899 }
1900 }
1901 return out
1902}
1903
1904// FileNameSpans splits a filename into matched/unmatched spans using ranges.
1905// Filename ranges live on line 1; columns are clamped to rune bounds.
1906func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan {
1907 runes := []rune(name)
1908 var intervals [][2]int
1909 for _, rg := range ranges {
1910 if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 {
1911 continue
1912 }
1913 c0 := max(0, min(int(rg.Start.Column)-1, len(runes)))
1914 c1 := max(0, min(int(rg.End.Column)-1, len(runes)))
1915 if c0 < c1 {
1916 intervals = append(intervals, [2]int{c0, c1})
1917 }
1918 }
1919 return spanRunes(runes, mergeIntervals(intervals))
1920}
1921
1922type CodeSearchParams struct {
1923 BaseParams
1924 FilterType string // "code"
1925 FilterQuery string
1926 Results []SearchResult
1927 Page pagination.Page
1928 HasMore bool
1929 ErrorMsg string
1930
1931 MatchCount int
1932 FileCount int
1933 TimeTaken time.Duration
1934}
1935
1936func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error {
1937 params.FilterType = "code"
1938 return p.execute("search/search", w, params)
1939}
1940
1941func (p *Pages) Home(w io.Writer, params TimelineParams) error {
1942 return p.execute("timeline/home", w, params)
1943}
1944
1945type CommentBodyFragmentParams struct {
1946 Comment models.Comment
1947 Reactions map[models.ReactionKind]models.ReactionDisplayData
1948 UserReacted map[models.ReactionKind]bool
1949}
1950
1951func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error {
1952 return p.executePlain("fragments/comment/commentBody", w, params)
1953}
1954
1955type PullCommentFragmentParams struct {
1956 LoggedInUser *oauth.MultiAccountUser
1957 Comment models.Comment
1958 Reactions map[models.ReactionKind]models.ReactionDisplayData
1959 UserReacted map[models.ReactionKind]bool
1960 HxSwapOob bool
1961}
1962
1963func (p *Pages) PullCommentFragment(w io.Writer, params PullCommentFragmentParams) error {
1964 return p.executePlain("fragments/comment/pullComment", w, params)
1965}
1966
1967type CommentHeaderFragmentParams struct {
1968 Comment models.Comment
1969 Reactions map[models.ReactionKind]models.ReactionDisplayData
1970 UserReacted map[models.ReactionKind]bool
1971 HxSwapOob bool
1972}
1973
1974func (p *Pages) CommentHeaderFragment(w io.Writer, params CommentHeaderFragmentParams) error {
1975 return p.executePlain("fragments/comment/commentHeader", w, params)
1976}
1977
1978type EditCommentFragmentParams struct {
1979 Comment models.Comment
1980}
1981
1982func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error {
1983 return p.executePlain("fragments/comment/edit", w, params)
1984}
1985
1986type ReplyCommentFragmentParams struct {
1987 BaseParams
1988}
1989
1990func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error {
1991 return p.executePlain("fragments/comment/reply", w, params)
1992}
1993
1994type ReplyPlaceholderFragmentParams struct {
1995 BaseParams
1996}
1997
1998func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error {
1999 return p.executePlain("fragments/comment/replyPlaceholder", w, params)
2000}
2001
2002func (p *Pages) Static() http.Handler {
2003 if p.dev {
2004 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
2005 }
2006
2007 sub, err := fs.Sub(p.embedFS, "static")
2008 if err != nil {
2009 p.logger.Error("no static dir found? that's crazy", "err", err)
2010 panic(err)
2011 }
2012 // Custom handler to apply Cache-Control headers for font files
2013 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
2014}
2015
2016func Cache(h http.Handler) http.Handler {
2017 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2018 path := strings.Split(r.URL.Path, "?")[0]
2019
2020 if strings.HasSuffix(path, ".css") {
2021 // on day for css files
2022 w.Header().Set("Cache-Control", "public, max-age=86400")
2023 } else {
2024 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
2025 }
2026 h.ServeHTTP(w, r)
2027 })
2028}
2029
2030func (p *Pages) CssContentHash() string {
2031 cssFile, err := p.embedFS.Open("static/tw.css")
2032 if err != nil {
2033 slog.Debug("Error opening CSS file", "err", err)
2034 return ""
2035 }
2036 defer cssFile.Close()
2037
2038 hasher := sha256.New()
2039 if _, err := io.Copy(hasher, cssFile); err != nil {
2040 slog.Debug("Error hashing CSS file", "err", err)
2041 return ""
2042 }
2043
2044 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
2045}
2046
2047func (p *Pages) DangerPasswordTokenStep(w io.Writer) error {
2048 return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil)
2049}
2050
2051func (p *Pages) DangerPasswordSuccess(w io.Writer) error {
2052 return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil)
2053}
2054
2055func (p *Pages) DangerDeleteTokenStep(w io.Writer) error {
2056 return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil)
2057}
2058
2059func (p *Pages) Error500(w io.Writer) error {
2060 return p.execute("errors/500", w, nil)
2061}
2062
2063func (p *Pages) Error404(w io.Writer) error {
2064 return p.execute("errors/404", w, nil)
2065}
2066
2067func (p *Pages) ErrorKnot404(w io.Writer) error {
2068 return p.execute("errors/knot404", w, nil)
2069}
2070
2071func (p *Pages) Error503(w io.Writer) error {
2072 return p.execute("errors/503", w, nil)
2073}