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