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