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