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