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