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 NeedsKnotUpgrade bool
961 KnotUnreachable bool
962 types.RepoIndexResponse
963}
964
965func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
966 params.Active = "overview"
967 if params.IsEmpty {
968 return p.executeRepo("repo/empty", w, params)
969 }
970
971 if params.NeedsKnotUpgrade {
972 return p.executeRepo("repo/needsUpgrade", w, params)
973 }
974
975 if params.KnotUnreachable {
976 return p.executeRepo("repo/knotUnreachable", w, params)
977 }
978
979 rctx := p.rctx.Clone()
980 rctx.RepoInfo = params.RepoInfo
981 rctx.RepoInfo.Ref = params.Ref
982 rctx.RendererType = markup.RendererTypeRepoMarkdown
983
984 if params.ReadmeFileName != "" {
985 switch markup.GetFormat(params.ReadmeFileName) {
986 case markup.FormatMarkdown:
987 params.Raw = false
988 htmlString := rctx.RenderMarkdown(params.Readme)
989 sanitized := sanitizer.SanitizeDefault(htmlString)
990 params.HTMLReadme = template.HTML(sanitized)
991 default:
992 params.Raw = true
993 }
994 }
995
996 return p.executeRepo("repo/index", w, params)
997}
998
999type RepoSearchParams struct {
1000 BaseParams
1001 RepoInfo repoinfo.RepoInfo
1002 Active string
1003 FilterQuery string
1004}
1005
1006func (p *Pages) RepoSearchPage(w io.Writer, params RepoSearchParams) error {
1007 params.Active = "overview"
1008 return p.executeRepo("repo/search", w, params)
1009}
1010
1011type RepoSearchResultsFragmentParams struct {
1012 Query string
1013 Results []SearchResult
1014 ErrorMsg string
1015}
1016
1017func (p *Pages) RepoSearchResultsFragment(w io.Writer, params RepoSearchResultsFragmentParams) error {
1018 return p.executePlain("repo/fragments/searchResults", w, params)
1019}
1020
1021type RepoLogParams struct {
1022 BaseParams
1023 RepoInfo repoinfo.RepoInfo
1024 TagMap map[string][]string
1025 Active string
1026 EmailToDid map[string]string
1027 VerifiedCommits commitverify.VerifiedCommits
1028
1029 types.RepoLogResponse
1030}
1031
1032func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
1033 params.Active = "overview"
1034 return p.executeRepo("repo/log", w, params)
1035}
1036
1037type RepoCommitParams struct {
1038 BaseParams
1039 RepoInfo repoinfo.RepoInfo
1040 Active string
1041 EmailToDid map[string]string
1042 Pipeline *types.Pipeline
1043 DiffOpts types.DiffOpts
1044
1045 // singular because it's always going to be just one
1046 VerifiedCommit commitverify.VerifiedCommits
1047
1048 types.RepoCommitResponse
1049}
1050
1051func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
1052 params.Active = "overview"
1053 return p.executeRepo("repo/commit", w, params)
1054}
1055
1056type RepoTreeParams struct {
1057 BaseParams
1058 RepoInfo repoinfo.RepoInfo
1059 Active string
1060 BreadCrumbs [][]string
1061 Path string
1062 Raw bool
1063 HTMLReadme template.HTML
1064 EmailToDid map[string]string
1065 LastCommitInfo *types.LastCommitInfo
1066 Ref string
1067 Parent string
1068 DotDot string
1069 Files []types.NiceTree
1070 ReadmeFileName string
1071 Readme string
1072}
1073
1074type RepoTreeStats struct {
1075 NumFolders uint64
1076 NumFiles uint64
1077}
1078
1079func (r RepoTreeParams) TreeStats() RepoTreeStats {
1080 numFolders, numFiles := 0, 0
1081 for _, f := range r.Files {
1082 if !f.IsFile() {
1083 numFolders += 1
1084 } else if f.IsFile() {
1085 numFiles += 1
1086 }
1087 }
1088
1089 return RepoTreeStats{
1090 NumFolders: uint64(numFolders),
1091 NumFiles: uint64(numFiles),
1092 }
1093}
1094
1095func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
1096 params.Active = "overview"
1097
1098 rctx := p.rctx.Clone()
1099 rctx.RepoInfo = params.RepoInfo
1100 rctx.RepoInfo.Ref = params.Ref
1101 rctx.RendererType = markup.RendererTypeRepoMarkdown
1102
1103 if params.ReadmeFileName != "" {
1104 switch markup.GetFormat(params.ReadmeFileName) {
1105 case markup.FormatMarkdown:
1106 params.Raw = false
1107 htmlString := rctx.RenderMarkdown(params.Readme)
1108 sanitized := sanitizer.SanitizeDefault(htmlString)
1109 params.HTMLReadme = template.HTML(sanitized)
1110 default:
1111 params.Raw = true
1112 }
1113 }
1114
1115 return p.executeRepo("repo/tree", w, params)
1116}
1117
1118type RepoBranchesParams struct {
1119 BaseParams
1120 RepoInfo repoinfo.RepoInfo
1121 Active string
1122 types.RepoBranchesResponse
1123}
1124
1125func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
1126 params.Active = "overview"
1127 return p.executeRepo("repo/branches", w, params)
1128}
1129
1130type RepoTagsParams struct {
1131 BaseParams
1132 RepoInfo repoinfo.RepoInfo
1133 Active string
1134 types.RepoTagsResponse
1135 ArtifactMap map[plumbing.Hash][]models.Artifact
1136 DanglingArtifacts []models.Artifact
1137}
1138
1139func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
1140 params.Active = "overview"
1141 return p.executeRepo("repo/tags", w, params)
1142}
1143
1144type RepoTagParams struct {
1145 BaseParams
1146 RepoInfo repoinfo.RepoInfo
1147 Active string
1148 types.RepoTagResponse
1149 ArtifactMap map[plumbing.Hash][]models.Artifact
1150 DanglingArtifacts []models.Artifact
1151}
1152
1153func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error {
1154 params.Active = "overview"
1155 return p.executeRepo("repo/tag", w, params)
1156}
1157
1158type RepoArtifactParams struct {
1159 BaseParams
1160 RepoInfo repoinfo.RepoInfo
1161 Artifact models.Artifact
1162}
1163
1164func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
1165 return p.executePlain("repo/fragments/artifact", w, params)
1166}
1167
1168type RepoBlobParams struct {
1169 BaseParams
1170 RepoInfo repoinfo.RepoInfo
1171 Active string // always "overview"
1172 BreadCrumbs [][]string
1173 BlobView models.BlobView // TODO: expose this struct
1174 ShowRendered bool
1175 EmailToDid map[string]string
1176 LastCommitInfo *types.LastCommitInfo
1177 Ref string
1178 Path string
1179 Language string
1180}
1181
1182func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
1183 params.Active = "overview"
1184 return p.executeRepo("repo/blob", w, params)
1185}
1186
1187type Collaborator struct {
1188 Did string
1189 Role string
1190}
1191
1192type RepoSettingsParams struct {
1193 BaseParams
1194 RepoInfo repoinfo.RepoInfo
1195 Collaborators []Collaborator
1196 Active string
1197 Branches []types.Branch
1198 Spindles []string
1199 CurrentSpindle string
1200 Secrets []*tangled.RepoListSecrets_Secret
1201
1202 // TODO: use repoinfo.roles
1203 IsCollaboratorInviteAllowed bool
1204}
1205
1206func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
1207 params.Active = "settings"
1208 return p.executeRepo("repo/settings", w, params)
1209}
1210
1211type RepoGeneralSettingsParams struct {
1212 BaseParams
1213 RepoInfo repoinfo.RepoInfo
1214 Labels []models.LabelDefinition
1215 DefaultLabels []models.LabelDefinition
1216 SubscribedLabels map[string]struct{}
1217 ShouldSubscribeAll bool
1218 Active string
1219 Tab string
1220 Branches []types.Branch
1221}
1222
1223func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
1224 params.Active = "settings"
1225 params.Tab = "general"
1226 return p.executeRepo("repo/settings/general", w, params)
1227}
1228
1229type RepoAccessSettingsParams struct {
1230 BaseParams
1231 RepoInfo repoinfo.RepoInfo
1232 Active string
1233 Tab string
1234 Collaborators []Collaborator
1235 CanRemoveCollaborator bool
1236}
1237
1238func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
1239 params.Active = "settings"
1240 params.Tab = "access"
1241 return p.executeRepo("repo/settings/access", w, params)
1242}
1243
1244type RepoPipelineSettingsParams struct {
1245 BaseParams
1246 RepoInfo repoinfo.RepoInfo
1247 Active string
1248 Tab string
1249 Spindles []string
1250 CurrentSpindle string
1251 Secrets []map[string]any
1252}
1253
1254func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
1255 params.Active = "settings"
1256 params.Tab = "pipelines"
1257 return p.executeRepo("repo/settings/pipelines", w, params)
1258}
1259
1260type RepoWebhooksSettingsParams struct {
1261 BaseParams
1262 RepoInfo repoinfo.RepoInfo
1263 Active string
1264 Tab string
1265 Webhooks []models.Webhook
1266 WebhookDeliveries map[int64][]models.WebhookDelivery
1267}
1268
1269func (p *Pages) RepoWebhooksSettings(w io.Writer, params RepoWebhooksSettingsParams) error {
1270 params.Active = "settings"
1271 params.Tab = "hooks"
1272 return p.executeRepo("repo/settings/hooks", w, params)
1273}
1274
1275type WebhookDeliveriesListParams struct {
1276 BaseParams
1277 RepoInfo repoinfo.RepoInfo
1278 Webhook *models.Webhook
1279 Deliveries []models.WebhookDelivery
1280}
1281
1282func (p *Pages) WebhookDeliveriesList(w io.Writer, params WebhookDeliveriesListParams) error {
1283 tpl, err := p.parse("repo/settings/fragments/webhookDeliveries")
1284 if err != nil {
1285 return err
1286 }
1287 return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params)
1288}
1289
1290type RepoSiteSettingsParams struct {
1291 BaseParams
1292 RepoInfo repoinfo.RepoInfo
1293 Active string
1294 Tab string
1295 Branches []types.Branch
1296 SiteConfig *models.RepoSite
1297 OwnerClaim *models.DomainClaim
1298 Deploys []models.SiteDeploy
1299 IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or ""
1300}
1301
1302func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error {
1303 params.Active = "settings"
1304 params.Tab = "sites"
1305 return p.executeRepo("repo/settings/sites", w, params)
1306}
1307
1308type RepoIssuesParams struct {
1309 BaseParams
1310 RepoInfo repoinfo.RepoInfo
1311 Active string
1312 Issues []models.Issue
1313 IssueCount int
1314 LabelDefs map[string]*models.LabelDefinition
1315 Page pagination.Page
1316 FilterState string
1317 FilterQuery string
1318 BaseFilterQuery string
1319 VouchRelationships map[syntax.DID]*models.VouchRelationship
1320}
1321
1322func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
1323 params.Active = "issues"
1324 return p.executeRepo("repo/issues/issues", w, params)
1325}
1326
1327type RepoSingleIssueParams struct {
1328 BaseParams
1329 RepoInfo repoinfo.RepoInfo
1330 Active string
1331 Issue *models.Issue
1332 CommentList []models.CommentListItem
1333 Backlinks []models.RichReferenceLink
1334 LabelDefs map[string]*models.LabelDefinition
1335
1336 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1337 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1338 VouchRelationships map[syntax.DID]*models.VouchRelationship
1339}
1340
1341func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
1342 params.Active = "issues"
1343 return p.executeRepo("repo/issues/issue", w, params)
1344}
1345
1346type EditIssueParams struct {
1347 BaseParams
1348 RepoInfo repoinfo.RepoInfo
1349 Issue *models.Issue
1350 Action string
1351}
1352
1353func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
1354 params.Action = "edit"
1355 return p.executePlain("repo/issues/fragments/putIssue", w, params)
1356}
1357
1358type ThreadReactionFragmentParams struct {
1359 Kind models.ReactionKind
1360 Count int
1361 Users []string
1362 IsReacted bool
1363 CommentRkey string
1364 SubjectUri string
1365}
1366
1367func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
1368 return p.executePlain("repo/fragments/reaction", w, params)
1369}
1370
1371type RepoNewIssueParams struct {
1372 BaseParams
1373 RepoInfo repoinfo.RepoInfo
1374 Issue *models.Issue // existing issue if any -- passed when editing
1375 Active string
1376 Action string
1377}
1378
1379func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
1380 params.Active = "issues"
1381 params.Action = "create"
1382 return p.executeRepo("repo/issues/new", w, params)
1383}
1384
1385type StackedDiff struct {
1386 Diff *types.NiceDiff
1387 Opts types.DiffOpts
1388}
1389
1390type RepoNewPullParams struct {
1391 BaseParams
1392 RepoInfo repoinfo.RepoInfo
1393 Branches []types.Branch
1394 SourceBranches []types.Branch
1395 ForkBranches []types.Branch
1396 Forks []models.Repo
1397 Source Source
1398 SourceBranch string
1399 TargetBranch string
1400 Fork string
1401 Patch string
1402 Title string
1403 Body string
1404 TitleDirty bool
1405 BodyDirty bool
1406 IsStacked bool
1407 Comparison *types.RepoFormatPatchResponse
1408 Diff *types.NiceDiff
1409 DiffOpts types.DiffOpts
1410 StackedDiffs []StackedDiff
1411 MergeCheck *types.MergeCheckResponse
1412 StackTitles map[string]string
1413 StackBodies map[string]string
1414 PrefillError string
1415 Active string
1416 LabelDefs map[string]*models.LabelDefinition
1417 LabelState models.LabelState
1418 StackLabelStates map[string]models.LabelState
1419}
1420
1421func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
1422 params.Active = "pulls"
1423 return p.executeRepo("repo/pulls/new", w, params)
1424}
1425
1426func (p *Pages) PullComposeHostFragment(w io.Writer, params RepoNewPullParams) error {
1427 return p.executePlain("repo/pulls/fragments/pullComposeHost", w, params)
1428}
1429
1430func (p *Pages) MarkdownPreviewFragment(w io.Writer, body string) error {
1431 return p.executePlain("fragments/markdownPreview", w, body)
1432}
1433
1434type RepoPullsParams struct {
1435 BaseParams
1436 RepoInfo repoinfo.RepoInfo
1437 Pulls []*models.Pull
1438 Active string
1439 FilterState string
1440 FilterQuery string
1441 BaseFilterQuery string
1442 Stacks []models.Stack
1443 Pipelines map[string]types.Pipeline
1444 LabelDefs map[string]*models.LabelDefinition
1445 Page pagination.Page
1446 PullCount int
1447 VouchRelationships map[syntax.DID]*models.VouchRelationship
1448}
1449
1450func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
1451 params.Active = "pulls"
1452 return p.executeRepo("repo/pulls/pulls", w, params)
1453}
1454
1455type ResubmitResult uint64
1456
1457const (
1458 ShouldResubmit ResubmitResult = iota
1459 ShouldNotResubmit
1460 Unknown
1461)
1462
1463func (r ResubmitResult) Yes() bool {
1464 return r == ShouldResubmit
1465}
1466func (r ResubmitResult) No() bool {
1467 return r == ShouldNotResubmit
1468}
1469func (r ResubmitResult) Unknown() bool {
1470 return r == Unknown
1471}
1472
1473type RepoSinglePullParams struct {
1474 BaseParams
1475 RepoInfo repoinfo.RepoInfo
1476 Active string
1477 Pull *models.Pull
1478 Stack models.Stack
1479 Backlinks []models.RichReferenceLink
1480 BranchDeleteStatus *models.BranchDeleteStatus
1481 MergeCheck types.MergeCheckResponse
1482 ResubmitCheck ResubmitResult
1483 Pipelines map[string]types.Pipeline
1484 Diff types.DiffRenderer
1485 DiffOpts types.DiffOpts
1486 ActiveRound int
1487 IsInterdiff bool
1488
1489 // WorkflowsChanged and ChangedWorkflowFiles describe whether the latest
1490 // round's patch touches .tangled/workflows/, for warning maintainers
1491 // before they manually trigger CI on a fork-based pull request.
1492 WorkflowsChanged bool
1493 ChangedWorkflowFiles []string
1494
1495 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1496 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1497
1498 LabelDefs map[string]*models.LabelDefinition
1499 VouchRelationships map[syntax.DID]*models.VouchRelationship
1500 VouchSkips map[syntax.DID]bool
1501}
1502
1503func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
1504 params.Active = "pulls"
1505 return p.executeRepo("repo/pulls/pull", w, params)
1506}
1507
1508type PullResubmitParams struct {
1509 BaseParams
1510 RepoInfo repoinfo.RepoInfo
1511 Pull *models.Pull
1512 SubmissionId int
1513}
1514
1515func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
1516 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
1517}
1518
1519type PullActionsParams struct {
1520 BaseParams
1521 RepoInfo repoinfo.RepoInfo
1522 Pull *models.Pull
1523 RoundNumber int
1524 MergeCheck types.MergeCheckResponse
1525 ResubmitCheck ResubmitResult
1526 BranchDeleteStatus *models.BranchDeleteStatus
1527 Stack models.Stack
1528
1529 // renders buttons in a pre-check state and attaches the hx-trigger="load"
1530 // that fetches the real, checked fragment
1531 Loading bool
1532}
1533
1534func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
1535 return p.executePlain("repo/pulls/fragments/pullActions", w, params)
1536}
1537
1538type PullNewCommentParams struct {
1539 BaseParams
1540 RepoInfo repoinfo.RepoInfo
1541 Pull *models.Pull
1542 RoundNumber int
1543}
1544
1545func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
1546 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
1547}
1548
1549type RepoCompareParams struct {
1550 BaseParams
1551 RepoInfo repoinfo.RepoInfo
1552 Forks []models.Repo
1553 Branches []types.Branch
1554 Tags []*types.TagReference
1555 Base string
1556 Head string
1557 Diff *types.NiceDiff
1558 DiffOpts types.DiffOpts
1559
1560 Active string
1561}
1562
1563func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error {
1564 params.Active = "overview"
1565 return p.executeRepo("repo/compare/compare", w, params)
1566}
1567
1568type RepoCompareNewParams struct {
1569 BaseParams
1570 RepoInfo repoinfo.RepoInfo
1571 Forks []models.Repo
1572 Branches []types.Branch
1573 Tags []*types.TagReference
1574 Base string
1575 Head string
1576
1577 Active string
1578}
1579
1580func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error {
1581 params.Active = "overview"
1582 return p.executeRepo("repo/compare/new", w, params)
1583}
1584
1585type RepoCompareAllowPullParams struct {
1586 BaseParams
1587 RepoInfo repoinfo.RepoInfo
1588 Base string
1589 Head string
1590}
1591
1592func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error {
1593 return p.executePlain("repo/fragments/compareAllowPull", w, params)
1594}
1595
1596type RepoCompareDiffFragmentParams struct {
1597 Diff types.NiceDiff
1598 DiffOpts types.DiffOpts
1599}
1600
1601func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
1602 return p.executePlain("repo/fragments/diff", w, []any{¶ms.Diff, ¶ms.DiffOpts})
1603}
1604
1605type LabelPanelParams struct {
1606 BaseParams
1607 RepoInfo repoinfo.RepoInfo
1608 Defs map[string]*models.LabelDefinition
1609 Subject string
1610 State models.LabelState
1611}
1612
1613func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
1614 return p.executePlain("repo/fragments/labelPanel", w, params)
1615}
1616
1617type EditLabelPanelParams struct {
1618 BaseParams
1619 RepoInfo repoinfo.RepoInfo
1620 Defs map[string]*models.LabelDefinition
1621 Subject string
1622 State models.LabelState
1623 Prefix string
1624}
1625
1626func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
1627 return p.executePlain("repo/fragments/editLabelPanel", w, params)
1628}
1629
1630type RepoStarsParams struct {
1631 BaseParams
1632 RepoInfo repoinfo.RepoInfo
1633 Active string
1634 Starrers []models.Star
1635 Page pagination.Page
1636 TotalCount int
1637}
1638
1639func (p *Pages) RepoStars(w io.Writer, params RepoStarsParams) error {
1640 params.Active = "overview"
1641 return p.executeRepo("repo/stars", w, params)
1642}
1643
1644type RepoForksParams struct {
1645 BaseParams
1646 RepoInfo repoinfo.RepoInfo
1647 Active string
1648 Forks []models.Repo
1649 Page pagination.Page
1650 TotalCount int
1651}
1652
1653func (p *Pages) RepoForks(w io.Writer, params RepoForksParams) error {
1654 params.Active = "overview"
1655 return p.executeRepo("repo/forks", w, params)
1656}
1657
1658type PipelinesParams struct {
1659 BaseParams
1660 RepoInfo repoinfo.RepoInfo
1661 Pipelines []types.Pipeline
1662 Active string
1663 FilterKind string
1664 Total int64
1665}
1666
1667func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
1668 params.Active = "pipelines"
1669 return p.executeRepo("repo/pipelines/pipelines", w, params)
1670}
1671
1672type LogBlockParams struct {
1673 Id int
1674 Name string
1675 Command string
1676 Collapsed bool
1677 StartTime time.Time
1678}
1679
1680func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1681 return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1682}
1683
1684type LogBlockEndParams struct {
1685 Id int
1686 StartTime time.Time
1687 EndTime time.Time
1688}
1689
1690func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1691 return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1692}
1693
1694type LogLineParams struct {
1695 Id int
1696 Content template.HTML
1697}
1698
1699func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
1700 return p.executePlain("repo/pipelines/fragments/logLine", w, params)
1701}
1702
1703type WorkflowSymbolOOBParams struct {
1704 Name string
1705 Statuses models.WorkflowStatus
1706}
1707
1708func (p *Pages) WorkflowSymbolOOB(w io.Writer, params WorkflowSymbolOOBParams) error {
1709 return p.executePlain("repo/pipelines/fragments/workflowSymbolOOB", w, params)
1710}
1711
1712type PipelineStatusesParams struct {
1713 RepoInfo repoinfo.RepoInfo
1714 Pipelines map[string]types.Pipeline
1715}
1716
1717func (p *Pages) PipelineStatusesFragment(w io.Writer, params PipelineStatusesParams) error {
1718 return p.executePlain("repo/fragments/commitPipelineStatuses", w, params)
1719}
1720
1721type WorkflowParams struct {
1722 BaseParams
1723 RepoInfo repoinfo.RepoInfo
1724 Pipeline types.Pipeline
1725 Workflow string
1726 LogUrl string
1727 Active string
1728}
1729
1730func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1731 params.Active = "pipelines"
1732 return p.executeRepo("repo/pipelines/workflow", w, params)
1733}
1734
1735type PutStringParams struct {
1736 BaseParams
1737 Action string
1738
1739 // this is supplied in the case of editing an existing string
1740 String models.String
1741}
1742
1743func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1744 return p.execute("strings/put", w, params)
1745}
1746
1747type StringsDashboardParams struct {
1748 BaseParams
1749 Card ProfileCard
1750 Strings []models.String
1751}
1752
1753func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1754 return p.execute("strings/dashboard", w, params)
1755}
1756
1757type StringTimelineParams struct {
1758 BaseParams
1759 Strings []models.String
1760}
1761
1762func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
1763 return p.execute("strings/timeline", w, params)
1764}
1765
1766type SingleStringParams struct {
1767 BaseParams
1768 ShowRendered bool
1769 RenderToggle bool
1770 RenderedContents template.HTML
1771 String *models.String
1772 Stats models.StringStats
1773 IsStarred bool
1774 StarCount int
1775 Owner identity.Identity
1776 CommentList []models.CommentListItem
1777
1778 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1779 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1780 VouchRelationships map[syntax.DID]*models.VouchRelationship
1781}
1782
1783func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1784 return p.execute("strings/string", w, params)
1785}
1786
1787type SearchReposParams struct {
1788 BaseParams
1789 FilterType string // "repo" | "code"
1790 Repos []SearchResult
1791 Page pagination.Page
1792 ResultCount int
1793 FilterQuery string
1794 SortParam string
1795 TimeTaken time.Duration
1796 DocCount int64
1797 ErrorMsg string
1798}
1799
1800func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error {
1801 params.FilterType = "repo"
1802 return p.execute("search/search", w, params)
1803}
1804
1805type SearchQuickParams struct {
1806 Repos []models.Repo
1807 Query string
1808 Total int
1809}
1810
1811func (p *Pages) SearchQuick(w io.Writer, params SearchQuickParams) error {
1812 return p.executePlain("search/fragments/quick", w, params)
1813}
1814
1815func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error {
1816 tpl, err := p.parse("search/fragments/quick")
1817 if err != nil {
1818 return err
1819 }
1820 return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params)
1821}
1822
1823type SearchResult struct {
1824 RepoDID syntax.DID
1825 Repo *models.Repo
1826 FilePath string
1827 Branches []string
1828 Commit string
1829 Language string
1830
1831 File *CodeSearchResult_File // filename match
1832 Chunks CodeSearchResult_Chunks // content matches
1833}
1834
1835// CodeSearchResult_Chunk is a content match with its lines pre-rendered.
1836type CodeSearchResult_Chunk struct {
1837 Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges
1838 MatchCount int // number of match ranges in this chunk
1839}
1840
1841type CodeSearchResult_Chunks []CodeSearchResult_Chunk
1842
1843func (cs CodeSearchResult_Chunks) MatchCount() int {
1844 count := 0
1845 for _, c := range cs {
1846 count += c.MatchCount
1847 }
1848 return count
1849}
1850
1851type CodeSearchResult_File struct {
1852 NameSpans []ChunkSpan // precomputed from FilePath/Ranges
1853}
1854
1855type ChunkSpan struct {
1856 Text string
1857 Match bool
1858}
1859
1860type ChunkLine struct {
1861 Num int
1862 Spans []ChunkSpan
1863 Highlight bool
1864}
1865
1866// ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each
1867// line into matched/unmatched spans using ranges. startLine is the 1-based line
1868// number of the first line.
1869func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine {
1870 if startLine < 1 {
1871 startLine = 1
1872 }
1873 // trim a single trailing newline so we don't emit a spurious empty line
1874 content = strings.TrimSuffix(content, "\n")
1875 lines := strings.Split(content, "\n")
1876 out := make([]ChunkLine, len(lines))
1877 for i, text := range lines {
1878 num := startLine + i
1879 runes := []rune(text)
1880
1881 // collect matched rune intervals [c0,c1) for this line
1882 var intervals [][2]int
1883 for _, rg := range ranges {
1884 if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) {
1885 continue
1886 }
1887 c0, c1 := 0, len(runes)
1888 if num == int(rg.Start.LineNumber) {
1889 c0 = int(rg.Start.Column) - 1
1890 }
1891 if num == int(rg.End.LineNumber) {
1892 c1 = int(rg.End.Column) - 1
1893 }
1894 c0 = max(0, min(c0, len(runes)))
1895 c1 = max(0, min(c1, len(runes)))
1896 if c0 < c1 {
1897 intervals = append(intervals, [2]int{c0, c1})
1898 }
1899 }
1900 intervals = mergeIntervals(intervals)
1901
1902 out[i] = ChunkLine{
1903 Num: num,
1904 Spans: spanRunes(runes, intervals),
1905 Highlight: len(intervals) > 0,
1906 }
1907 }
1908 return out
1909}
1910
1911// FileNameSpans splits a filename into matched/unmatched spans using ranges.
1912// Filename ranges live on line 1; columns are clamped to rune bounds.
1913func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan {
1914 runes := []rune(name)
1915 var intervals [][2]int
1916 for _, rg := range ranges {
1917 if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 {
1918 continue
1919 }
1920 c0 := max(0, min(int(rg.Start.Column)-1, len(runes)))
1921 c1 := max(0, min(int(rg.End.Column)-1, len(runes)))
1922 if c0 < c1 {
1923 intervals = append(intervals, [2]int{c0, c1})
1924 }
1925 }
1926 return spanRunes(runes, mergeIntervals(intervals))
1927}
1928
1929type CodeSearchParams struct {
1930 BaseParams
1931 FilterType string // "code"
1932 FilterQuery string
1933 Results []SearchResult
1934 Page pagination.Page
1935 HasMore bool
1936 ErrorMsg string
1937
1938 MatchCount int
1939 FileCount int
1940 TimeTaken time.Duration
1941}
1942
1943func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error {
1944 params.FilterType = "code"
1945 return p.execute("search/search", w, params)
1946}
1947
1948func (p *Pages) Home(w io.Writer, params TimelineParams) error {
1949 return p.execute("timeline/home", w, params)
1950}
1951
1952type CommentBodyFragmentParams struct {
1953 Comment models.Comment
1954 Reactions map[models.ReactionKind]models.ReactionDisplayData
1955 UserReacted map[models.ReactionKind]bool
1956}
1957
1958func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error {
1959 return p.executePlain("fragments/comment/commentBody", w, params)
1960}
1961
1962type PullCommentFragmentParams struct {
1963 LoggedInUser *oauth.MultiAccountUser
1964 Comment models.Comment
1965 Reactions map[models.ReactionKind]models.ReactionDisplayData
1966 UserReacted map[models.ReactionKind]bool
1967 HxSwapOob bool
1968}
1969
1970func (p *Pages) PullCommentFragment(w io.Writer, params PullCommentFragmentParams) error {
1971 return p.executePlain("fragments/comment/pullComment", w, params)
1972}
1973
1974type CommentHeaderFragmentParams struct {
1975 Comment models.Comment
1976 Reactions map[models.ReactionKind]models.ReactionDisplayData
1977 UserReacted map[models.ReactionKind]bool
1978 HxSwapOob bool
1979}
1980
1981func (p *Pages) CommentHeaderFragment(w io.Writer, params CommentHeaderFragmentParams) error {
1982 return p.executePlain("fragments/comment/commentHeader", w, params)
1983}
1984
1985type EditCommentFragmentParams struct {
1986 Comment models.Comment
1987}
1988
1989func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error {
1990 return p.executePlain("fragments/comment/edit", w, params)
1991}
1992
1993type ReplyCommentFragmentParams struct {
1994 BaseParams
1995}
1996
1997func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error {
1998 return p.executePlain("fragments/comment/reply", w, params)
1999}
2000
2001type ReplyPlaceholderFragmentParams struct {
2002 BaseParams
2003}
2004
2005func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error {
2006 return p.executePlain("fragments/comment/replyPlaceholder", w, params)
2007}
2008
2009func (p *Pages) Static() http.Handler {
2010 if p.dev {
2011 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
2012 }
2013
2014 sub, err := fs.Sub(p.embedFS, "static")
2015 if err != nil {
2016 p.logger.Error("no static dir found? that's crazy", "err", err)
2017 panic(err)
2018 }
2019 // Custom handler to apply Cache-Control headers for font files
2020 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
2021}
2022
2023func Cache(h http.Handler) http.Handler {
2024 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2025 path := strings.Split(r.URL.Path, "?")[0]
2026
2027 if strings.HasSuffix(path, ".css") {
2028 // on day for css files
2029 w.Header().Set("Cache-Control", "public, max-age=86400")
2030 } else {
2031 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
2032 }
2033 h.ServeHTTP(w, r)
2034 })
2035}
2036
2037func (p *Pages) CssContentHash() string {
2038 cssFile, err := p.embedFS.Open("static/tw.css")
2039 if err != nil {
2040 slog.Debug("Error opening CSS file", "err", err)
2041 return ""
2042 }
2043 defer cssFile.Close()
2044
2045 hasher := sha256.New()
2046 if _, err := io.Copy(hasher, cssFile); err != nil {
2047 slog.Debug("Error hashing CSS file", "err", err)
2048 return ""
2049 }
2050
2051 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
2052}
2053
2054func (p *Pages) DangerPasswordTokenStep(w io.Writer) error {
2055 return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil)
2056}
2057
2058func (p *Pages) DangerPasswordSuccess(w io.Writer) error {
2059 return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil)
2060}
2061
2062func (p *Pages) DangerDeleteTokenStep(w io.Writer) error {
2063 return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil)
2064}
2065
2066func (p *Pages) Error500(w io.Writer) error {
2067 return p.execute("errors/500", w, nil)
2068}
2069
2070func (p *Pages) Error404(w io.Writer) error {
2071 return p.execute("errors/404", w, nil)
2072}
2073
2074func (p *Pages) ErrorKnot404(w io.Writer) error {
2075 return p.execute("errors/knot404", w, nil)
2076}
2077
2078func (p *Pages) Error503(w io.Writer) error {
2079 return p.execute("errors/503", w, nil)
2080}