forked from
tangled.org/core
Monorepo for Tangled
53 kB
2082 lines
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 HxSwapOob struct{} // empty struct so always truthy
859}
860
861func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
862 return p.executePlain("user/fragments/follow-oob", w, params)
863}
864
865type ProfilePopoverParams struct {
866 BaseParams
867 UserDid string
868 Profile *models.Profile
869 FollowStatus models.FollowStatus
870 VouchRelationship *models.VouchRelationship
871 Stats ProfilePopoverStats
872}
873
874type ProfilePopoverStats struct {
875 FollowersCount int64
876 FollowingCount int64
877}
878
879func (p *Pages) ProfilePopoverFragment(w io.Writer, params ProfilePopoverParams) error {
880 return p.executePlain("user/fragments/profilePopover", w, params)
881}
882
883type EditBioParams struct {
884 BaseParams
885 Profile *models.Profile
886 AlsoKnownAs []string
887 // Action optionally overrides the form's hx-post target. Defaults to
888 // /profile/bio when empty. Used by the onboarding flow to save + advance.
889 Action string
890}
891
892func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
893 return p.executePlain("user/fragments/editBio", w, params)
894}
895
896type EditPinsParams struct {
897 BaseParams
898 Profile *models.Profile
899 AllRepos []PinnedRepo
900}
901
902type PinnedRepo struct {
903 IsPinned bool
904 models.Repo
905}
906
907func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
908 return p.executePlain("user/fragments/editPins", w, params)
909}
910
911type StarBtnFragmentParams struct {
912 IsStarred bool
913 SubjectAt syntax.ATURI
914 StarCount int
915 RepoName string
916 HxSwapOob bool
917}
918
919func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
920 params.HxSwapOob = true
921 return p.executePlain("fragments/starBtn", w, params)
922}
923
924type OnboardingParams struct {
925 BaseParams
926 Step int
927
928 EditBio EditBioParams
929
930 PubKeys []models.PublicKey
931
932 People []FollowCard
933 TrendingRepos []models.Repo
934 StarStatuses map[string]bool
935}
936
937// KeyFragment renders a single SSH key row (used to append a newly added key
938// to the list without a full reload).
939func (p *Pages) KeyFragment(w io.Writer, key models.PublicKey) error {
940 return p.executePlain("user/settings/fragments/keyListing", w, key)
941}
942
943func (p *Pages) Onboarding(w io.Writer, params OnboardingParams) error {
944 return p.executeOnboarding("onboarding/welcome", w, params)
945}
946
947type RepoIndexParams struct {
948 BaseParams
949 RepoInfo repoinfo.RepoInfo
950 Active string
951 TagMap map[string][]string
952 CommitsTrunc []types.Commit
953 TagsTrunc []*types.TagReference
954 BranchesTrunc []types.Branch
955 // ForkInfo *types.ForkInfo
956 HTMLReadme template.HTML
957 Raw bool
958 EmailToDid map[string]string
959 VerifiedCommits commitverify.VerifiedCommits
960 Languages []types.RepoLanguageDetails
961 NeedsKnotUpgrade bool
962 KnotUnreachable bool
963 types.RepoIndexResponse
964}
965
966func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
967 params.Active = "overview"
968 if params.IsEmpty {
969 return p.executeRepo("repo/empty", w, params)
970 }
971
972 if params.NeedsKnotUpgrade {
973 return p.executeRepo("repo/needsUpgrade", w, params)
974 }
975
976 if params.KnotUnreachable {
977 return p.executeRepo("repo/knotUnreachable", w, params)
978 }
979
980 rctx := p.rctx.Clone()
981 rctx.RepoInfo = params.RepoInfo
982 rctx.RepoInfo.Ref = params.Ref
983 rctx.RendererType = markup.RendererTypeRepoMarkdown
984
985 if params.ReadmeFileName != "" {
986 switch markup.GetFormat(params.ReadmeFileName) {
987 case markup.FormatMarkdown:
988 params.Raw = false
989 htmlString := rctx.RenderMarkdown(params.Readme)
990 sanitized := sanitizer.SanitizeDefault(htmlString)
991 params.HTMLReadme = template.HTML(sanitized)
992 default:
993 params.Raw = true
994 }
995 }
996
997 return p.executeRepo("repo/index", w, params)
998}
999
1000type RepoSearchParams struct {
1001 BaseParams
1002 RepoInfo repoinfo.RepoInfo
1003 Active string
1004 FilterQuery string
1005}
1006
1007func (p *Pages) RepoSearchPage(w io.Writer, params RepoSearchParams) error {
1008 params.Active = "overview"
1009 return p.executeRepo("repo/search", w, params)
1010}
1011
1012type RepoSearchResultsFragmentParams struct {
1013 Query string
1014 Results []SearchResult
1015 ErrorMsg string
1016}
1017
1018func (p *Pages) RepoSearchResultsFragment(w io.Writer, params RepoSearchResultsFragmentParams) error {
1019 return p.executePlain("repo/fragments/searchResults", w, params)
1020}
1021
1022type RepoLogParams struct {
1023 BaseParams
1024 RepoInfo repoinfo.RepoInfo
1025 TagMap map[string][]string
1026 Active string
1027 EmailToDid map[string]string
1028 VerifiedCommits commitverify.VerifiedCommits
1029
1030 types.RepoLogResponse
1031}
1032
1033func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
1034 params.Active = "overview"
1035 return p.executeRepo("repo/log", w, params)
1036}
1037
1038type RepoCommitParams struct {
1039 BaseParams
1040 RepoInfo repoinfo.RepoInfo
1041 Active string
1042 EmailToDid map[string]string
1043 Pipeline *types.Pipeline
1044 DiffOpts types.DiffOpts
1045
1046 // singular because it's always going to be just one
1047 VerifiedCommit commitverify.VerifiedCommits
1048
1049 types.RepoCommitResponse
1050}
1051
1052func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
1053 params.Active = "overview"
1054 return p.executeRepo("repo/commit", w, params)
1055}
1056
1057type RepoTreeParams struct {
1058 BaseParams
1059 RepoInfo repoinfo.RepoInfo
1060 Active string
1061 BreadCrumbs [][]string
1062 Path string
1063 Raw bool
1064 HTMLReadme template.HTML
1065 EmailToDid map[string]string
1066 LastCommitInfo *types.LastCommitInfo
1067 Ref string
1068 Parent string
1069 DotDot string
1070 Files []types.NiceTree
1071 ReadmeFileName string
1072 Readme string
1073}
1074
1075type RepoTreeStats struct {
1076 NumFolders uint64
1077 NumFiles uint64
1078}
1079
1080func (r RepoTreeParams) TreeStats() RepoTreeStats {
1081 numFolders, numFiles := 0, 0
1082 for _, f := range r.Files {
1083 if !f.IsFile() {
1084 numFolders += 1
1085 } else if f.IsFile() {
1086 numFiles += 1
1087 }
1088 }
1089
1090 return RepoTreeStats{
1091 NumFolders: uint64(numFolders),
1092 NumFiles: uint64(numFiles),
1093 }
1094}
1095
1096func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
1097 params.Active = "overview"
1098
1099 rctx := p.rctx.Clone()
1100 rctx.RepoInfo = params.RepoInfo
1101 rctx.RepoInfo.Ref = params.Ref
1102 rctx.RendererType = markup.RendererTypeRepoMarkdown
1103
1104 if params.ReadmeFileName != "" {
1105 switch markup.GetFormat(params.ReadmeFileName) {
1106 case markup.FormatMarkdown:
1107 params.Raw = false
1108 htmlString := rctx.RenderMarkdown(params.Readme)
1109 sanitized := sanitizer.SanitizeDefault(htmlString)
1110 params.HTMLReadme = template.HTML(sanitized)
1111 default:
1112 params.Raw = true
1113 }
1114 }
1115
1116 return p.executeRepo("repo/tree", w, params)
1117}
1118
1119type RepoBranchesParams struct {
1120 BaseParams
1121 RepoInfo repoinfo.RepoInfo
1122 Active string
1123 types.RepoBranchesResponse
1124}
1125
1126func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
1127 params.Active = "overview"
1128 return p.executeRepo("repo/branches", w, params)
1129}
1130
1131type RepoTagsParams struct {
1132 BaseParams
1133 RepoInfo repoinfo.RepoInfo
1134 Active string
1135 types.RepoTagsResponse
1136 ArtifactMap map[plumbing.Hash][]models.Artifact
1137 DanglingArtifacts []models.Artifact
1138}
1139
1140func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
1141 params.Active = "overview"
1142 return p.executeRepo("repo/tags", w, params)
1143}
1144
1145type RepoTagParams struct {
1146 BaseParams
1147 RepoInfo repoinfo.RepoInfo
1148 Active string
1149 types.RepoTagResponse
1150 ArtifactMap map[plumbing.Hash][]models.Artifact
1151 DanglingArtifacts []models.Artifact
1152}
1153
1154func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error {
1155 params.Active = "overview"
1156 return p.executeRepo("repo/tag", w, params)
1157}
1158
1159type RepoArtifactParams struct {
1160 BaseParams
1161 RepoInfo repoinfo.RepoInfo
1162 Artifact models.Artifact
1163}
1164
1165func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
1166 return p.executePlain("repo/fragments/artifact", w, params)
1167}
1168
1169type RepoBlobParams struct {
1170 BaseParams
1171 RepoInfo repoinfo.RepoInfo
1172 Active string // always "overview"
1173 BreadCrumbs [][]string
1174 BlobView models.BlobView // TODO: expose this struct
1175 ShowRendered bool
1176 EmailToDid map[string]string
1177 LastCommitInfo *types.LastCommitInfo
1178 Ref string
1179 Path string
1180 Language string
1181}
1182
1183func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
1184 params.Active = "overview"
1185 return p.executeRepo("repo/blob", w, params)
1186}
1187
1188type Collaborator struct {
1189 Did string
1190 Role string
1191}
1192
1193type RepoSettingsParams struct {
1194 BaseParams
1195 RepoInfo repoinfo.RepoInfo
1196 Collaborators []Collaborator
1197 Active string
1198 Branches []types.Branch
1199 Spindles []string
1200 CurrentSpindle string
1201 Secrets []*tangled.RepoListSecrets_Secret
1202
1203 // TODO: use repoinfo.roles
1204 IsCollaboratorInviteAllowed bool
1205}
1206
1207func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
1208 params.Active = "settings"
1209 return p.executeRepo("repo/settings", w, params)
1210}
1211
1212type RepoGeneralSettingsParams struct {
1213 BaseParams
1214 RepoInfo repoinfo.RepoInfo
1215 Labels []models.LabelDefinition
1216 DefaultLabels []models.LabelDefinition
1217 SubscribedLabels map[string]struct{}
1218 ShouldSubscribeAll bool
1219 Active string
1220 Tab string
1221 Branches []types.Branch
1222}
1223
1224func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
1225 params.Active = "settings"
1226 params.Tab = "general"
1227 return p.executeRepo("repo/settings/general", w, params)
1228}
1229
1230type RepoAccessSettingsParams struct {
1231 BaseParams
1232 RepoInfo repoinfo.RepoInfo
1233 Active string
1234 Tab string
1235 Collaborators []Collaborator
1236 CanRemoveCollaborator bool
1237}
1238
1239func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
1240 params.Active = "settings"
1241 params.Tab = "access"
1242 return p.executeRepo("repo/settings/access", w, params)
1243}
1244
1245type RepoPipelineSettingsParams struct {
1246 BaseParams
1247 RepoInfo repoinfo.RepoInfo
1248 Active string
1249 Tab string
1250 Spindles []string
1251 CurrentSpindle string
1252 Secrets []map[string]any
1253}
1254
1255func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
1256 params.Active = "settings"
1257 params.Tab = "pipelines"
1258 return p.executeRepo("repo/settings/pipelines", w, params)
1259}
1260
1261type RepoWebhooksSettingsParams struct {
1262 BaseParams
1263 RepoInfo repoinfo.RepoInfo
1264 Active string
1265 Tab string
1266 Webhooks []models.Webhook
1267 WebhookDeliveries map[int64][]models.WebhookDelivery
1268}
1269
1270func (p *Pages) RepoWebhooksSettings(w io.Writer, params RepoWebhooksSettingsParams) error {
1271 params.Active = "settings"
1272 params.Tab = "hooks"
1273 return p.executeRepo("repo/settings/hooks", w, params)
1274}
1275
1276type WebhookDeliveriesListParams struct {
1277 BaseParams
1278 RepoInfo repoinfo.RepoInfo
1279 Webhook *models.Webhook
1280 Deliveries []models.WebhookDelivery
1281}
1282
1283func (p *Pages) WebhookDeliveriesList(w io.Writer, params WebhookDeliveriesListParams) error {
1284 tpl, err := p.parse("repo/settings/fragments/webhookDeliveries")
1285 if err != nil {
1286 return err
1287 }
1288 return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params)
1289}
1290
1291type RepoSiteSettingsParams struct {
1292 BaseParams
1293 RepoInfo repoinfo.RepoInfo
1294 Active string
1295 Tab string
1296 Branches []types.Branch
1297 SiteConfig *models.RepoSite
1298 OwnerClaim *models.DomainClaim
1299 Deploys []models.SiteDeploy
1300 IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or ""
1301}
1302
1303func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error {
1304 params.Active = "settings"
1305 params.Tab = "sites"
1306 return p.executeRepo("repo/settings/sites", w, params)
1307}
1308
1309type RepoIssuesParams struct {
1310 BaseParams
1311 RepoInfo repoinfo.RepoInfo
1312 Active string
1313 Issues []models.Issue
1314 IssueCount int
1315 LabelDefs map[string]*models.LabelDefinition
1316 Page pagination.Page
1317 FilterState string
1318 FilterQuery string
1319 BaseFilterQuery string
1320 VouchRelationships map[syntax.DID]*models.VouchRelationship
1321}
1322
1323func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
1324 params.Active = "issues"
1325 return p.executeRepo("repo/issues/issues", w, params)
1326}
1327
1328type RepoSingleIssueParams struct {
1329 BaseParams
1330 RepoInfo repoinfo.RepoInfo
1331 Active string
1332 Issue *models.Issue
1333 CommentList []models.CommentListItem
1334 Backlinks []models.RichReferenceLink
1335 LabelDefs map[string]*models.LabelDefinition
1336
1337 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1338 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1339 VouchRelationships map[syntax.DID]*models.VouchRelationship
1340}
1341
1342func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
1343 params.Active = "issues"
1344 return p.executeRepo("repo/issues/issue", w, params)
1345}
1346
1347type EditIssueParams struct {
1348 BaseParams
1349 RepoInfo repoinfo.RepoInfo
1350 Issue *models.Issue
1351 Action string
1352}
1353
1354func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
1355 params.Action = "edit"
1356 return p.executePlain("repo/issues/fragments/putIssue", w, params)
1357}
1358
1359type ThreadReactionFragmentParams struct {
1360 Kind models.ReactionKind
1361 Count int
1362 Users []string
1363 IsReacted bool
1364 CommentRkey string
1365 SubjectUri string
1366}
1367
1368func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
1369 return p.executePlain("repo/fragments/reaction", w, params)
1370}
1371
1372type RepoNewIssueParams struct {
1373 BaseParams
1374 RepoInfo repoinfo.RepoInfo
1375 Issue *models.Issue // existing issue if any -- passed when editing
1376 Active string
1377 Action string
1378}
1379
1380func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
1381 params.Active = "issues"
1382 params.Action = "create"
1383 return p.executeRepo("repo/issues/new", w, params)
1384}
1385
1386type StackedDiff struct {
1387 Diff *types.NiceDiff
1388 Opts types.DiffOpts
1389}
1390
1391type RepoNewPullParams struct {
1392 BaseParams
1393 RepoInfo repoinfo.RepoInfo
1394 Branches []types.Branch
1395 SourceBranches []types.Branch
1396 ForkBranches []types.Branch
1397 Forks []models.Repo
1398 Source Source
1399 SourceBranch string
1400 TargetBranch string
1401 Fork string
1402 Patch string
1403 Title string
1404 Body string
1405 TitleDirty bool
1406 BodyDirty bool
1407 IsStacked bool
1408 Comparison *types.RepoFormatPatchResponse
1409 Diff *types.NiceDiff
1410 DiffOpts types.DiffOpts
1411 StackedDiffs []StackedDiff
1412 MergeCheck *types.MergeCheckResponse
1413 StackTitles map[string]string
1414 StackBodies map[string]string
1415 PrefillError string
1416 Active string
1417 LabelDefs map[string]*models.LabelDefinition
1418 LabelState models.LabelState
1419 StackLabelStates map[string]models.LabelState
1420}
1421
1422func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
1423 params.Active = "pulls"
1424 return p.executeRepo("repo/pulls/new", w, params)
1425}
1426
1427func (p *Pages) PullComposeHostFragment(w io.Writer, params RepoNewPullParams) error {
1428 return p.executePlain("repo/pulls/fragments/pullComposeHost", w, params)
1429}
1430
1431func (p *Pages) MarkdownPreviewFragment(w io.Writer, body string) error {
1432 return p.executePlain("fragments/markdownPreview", w, body)
1433}
1434
1435type RepoPullsParams struct {
1436 BaseParams
1437 RepoInfo repoinfo.RepoInfo
1438 Pulls []*models.Pull
1439 Active string
1440 FilterState string
1441 FilterQuery string
1442 BaseFilterQuery string
1443 Stacks []models.Stack
1444 Pipelines map[string]types.Pipeline
1445 LabelDefs map[string]*models.LabelDefinition
1446 Page pagination.Page
1447 PullCount int
1448 VouchRelationships map[syntax.DID]*models.VouchRelationship
1449}
1450
1451func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
1452 params.Active = "pulls"
1453 return p.executeRepo("repo/pulls/pulls", w, params)
1454}
1455
1456type ResubmitResult uint64
1457
1458const (
1459 ShouldResubmit ResubmitResult = iota
1460 ShouldNotResubmit
1461 Unknown
1462)
1463
1464func (r ResubmitResult) Yes() bool {
1465 return r == ShouldResubmit
1466}
1467func (r ResubmitResult) No() bool {
1468 return r == ShouldNotResubmit
1469}
1470func (r ResubmitResult) Unknown() bool {
1471 return r == Unknown
1472}
1473
1474type RepoSinglePullParams struct {
1475 BaseParams
1476 RepoInfo repoinfo.RepoInfo
1477 Active string
1478 Pull *models.Pull
1479 Stack models.Stack
1480 Backlinks []models.RichReferenceLink
1481 BranchDeleteStatus *models.BranchDeleteStatus
1482 MergeCheck types.MergeCheckResponse
1483 ResubmitCheck ResubmitResult
1484 Pipelines map[string]types.Pipeline
1485 Diff types.DiffRenderer
1486 DiffOpts types.DiffOpts
1487 ActiveRound int
1488 IsInterdiff bool
1489
1490 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1491 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1492
1493 LabelDefs map[string]*models.LabelDefinition
1494 VouchRelationships map[syntax.DID]*models.VouchRelationship
1495 VouchSkips map[syntax.DID]bool
1496}
1497
1498func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
1499 params.Active = "pulls"
1500 return p.executeRepo("repo/pulls/pull", w, params)
1501}
1502
1503type PullResubmitParams struct {
1504 BaseParams
1505 RepoInfo repoinfo.RepoInfo
1506 Pull *models.Pull
1507 SubmissionId int
1508}
1509
1510func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
1511 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
1512}
1513
1514type PullActionsParams struct {
1515 BaseParams
1516 RepoInfo repoinfo.RepoInfo
1517 Pull *models.Pull
1518 RoundNumber int
1519 MergeCheck types.MergeCheckResponse
1520 ResubmitCheck ResubmitResult
1521 BranchDeleteStatus *models.BranchDeleteStatus
1522 Stack models.Stack
1523
1524 // Workflow warning state for fork-based pulls without a pipeline on the
1525 // latest commit. WorkflowsChanged and ChangedWorkflowFiles are computed
1526 // from the latest round's patch.
1527 WorkflowsChanged bool
1528 ChangedWorkflowFiles []string
1529 HasPipeline bool
1530
1531 // renders buttons in a pre-check state and attaches the hx-trigger="load"
1532 // that fetches the real, checked fragment
1533 Loading bool
1534}
1535
1536func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
1537 return p.executePlain("repo/pulls/fragments/pullActions", w, params)
1538}
1539
1540type PullNewCommentParams struct {
1541 BaseParams
1542 RepoInfo repoinfo.RepoInfo
1543 Pull *models.Pull
1544 RoundNumber int
1545}
1546
1547func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
1548 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
1549}
1550
1551type RepoCompareParams struct {
1552 BaseParams
1553 RepoInfo repoinfo.RepoInfo
1554 Forks []models.Repo
1555 Branches []types.Branch
1556 Tags []*types.TagReference
1557 Base string
1558 Head string
1559 Diff *types.NiceDiff
1560 DiffOpts types.DiffOpts
1561
1562 Active string
1563}
1564
1565func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error {
1566 params.Active = "overview"
1567 return p.executeRepo("repo/compare/compare", w, params)
1568}
1569
1570type RepoCompareNewParams struct {
1571 BaseParams
1572 RepoInfo repoinfo.RepoInfo
1573 Forks []models.Repo
1574 Branches []types.Branch
1575 Tags []*types.TagReference
1576 Base string
1577 Head string
1578
1579 Active string
1580}
1581
1582func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error {
1583 params.Active = "overview"
1584 return p.executeRepo("repo/compare/new", w, params)
1585}
1586
1587type RepoCompareAllowPullParams struct {
1588 BaseParams
1589 RepoInfo repoinfo.RepoInfo
1590 Base string
1591 Head string
1592}
1593
1594func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error {
1595 return p.executePlain("repo/fragments/compareAllowPull", w, params)
1596}
1597
1598type RepoCompareDiffFragmentParams struct {
1599 Diff types.NiceDiff
1600 DiffOpts types.DiffOpts
1601}
1602
1603func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
1604 return p.executePlain("repo/fragments/diff", w, []any{¶ms.Diff, ¶ms.DiffOpts})
1605}
1606
1607type LabelPanelParams struct {
1608 BaseParams
1609 RepoInfo repoinfo.RepoInfo
1610 Defs map[string]*models.LabelDefinition
1611 Subject string
1612 State models.LabelState
1613}
1614
1615func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
1616 return p.executePlain("repo/fragments/labelPanel", w, params)
1617}
1618
1619type EditLabelPanelParams struct {
1620 BaseParams
1621 RepoInfo repoinfo.RepoInfo
1622 Defs map[string]*models.LabelDefinition
1623 Subject string
1624 State models.LabelState
1625 Prefix string
1626}
1627
1628func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
1629 return p.executePlain("repo/fragments/editLabelPanel", w, params)
1630}
1631
1632type RepoStarsParams struct {
1633 BaseParams
1634 RepoInfo repoinfo.RepoInfo
1635 Active string
1636 Starrers []models.Star
1637 Page pagination.Page
1638 TotalCount int
1639}
1640
1641func (p *Pages) RepoStars(w io.Writer, params RepoStarsParams) error {
1642 params.Active = "overview"
1643 return p.executeRepo("repo/stars", w, params)
1644}
1645
1646type RepoForksParams struct {
1647 BaseParams
1648 RepoInfo repoinfo.RepoInfo
1649 Active string
1650 Forks []models.Repo
1651 Page pagination.Page
1652 TotalCount int
1653}
1654
1655func (p *Pages) RepoForks(w io.Writer, params RepoForksParams) error {
1656 params.Active = "overview"
1657 return p.executeRepo("repo/forks", w, params)
1658}
1659
1660type PipelinesParams struct {
1661 BaseParams
1662 RepoInfo repoinfo.RepoInfo
1663 Pipelines []types.Pipeline
1664 Active string
1665 FilterKind string
1666 Total int64
1667}
1668
1669func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
1670 params.Active = "pipelines"
1671 return p.executeRepo("repo/pipelines/pipelines", w, params)
1672}
1673
1674type LogBlockParams struct {
1675 Id int
1676 Name string
1677 Command string
1678 Collapsed bool
1679 StartTime time.Time
1680}
1681
1682func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1683 return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1684}
1685
1686type LogBlockEndParams struct {
1687 Id int
1688 StartTime time.Time
1689 EndTime time.Time
1690}
1691
1692func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
1693 return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
1694}
1695
1696type LogLineParams struct {
1697 Id int
1698 Content template.HTML
1699}
1700
1701func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
1702 return p.executePlain("repo/pipelines/fragments/logLine", w, params)
1703}
1704
1705type WorkflowSymbolOOBParams struct {
1706 Name string
1707 Statuses models.WorkflowStatus
1708}
1709
1710func (p *Pages) WorkflowSymbolOOB(w io.Writer, params WorkflowSymbolOOBParams) error {
1711 return p.executePlain("repo/pipelines/fragments/workflowSymbolOOB", w, params)
1712}
1713
1714type PipelineStatusesParams struct {
1715 RepoInfo repoinfo.RepoInfo
1716 Pipelines map[string]types.Pipeline
1717}
1718
1719func (p *Pages) PipelineStatusesFragment(w io.Writer, params PipelineStatusesParams) error {
1720 return p.executePlain("repo/fragments/commitPipelineStatuses", w, params)
1721}
1722
1723type WorkflowParams struct {
1724 BaseParams
1725 RepoInfo repoinfo.RepoInfo
1726 Pipeline types.Pipeline
1727 Workflow string
1728 SSHLogCommand string
1729 Active string
1730}
1731
1732func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1733 params.Active = "pipelines"
1734 return p.executeRepo("repo/pipelines/workflow", w, params)
1735}
1736
1737type PutStringParams struct {
1738 BaseParams
1739 Action string
1740
1741 // this is supplied in the case of editing an existing string
1742 String models.String
1743}
1744
1745func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1746 return p.execute("strings/put", w, params)
1747}
1748
1749type StringsDashboardParams struct {
1750 BaseParams
1751 Card ProfileCard
1752 Strings []models.String
1753}
1754
1755func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1756 return p.execute("strings/dashboard", w, params)
1757}
1758
1759type StringTimelineParams struct {
1760 BaseParams
1761 Strings []models.String
1762}
1763
1764func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
1765 return p.execute("strings/timeline", w, params)
1766}
1767
1768type SingleStringParams struct {
1769 BaseParams
1770 ShowRendered bool
1771 RenderToggle bool
1772 RenderedContents template.HTML
1773 String *models.String
1774 Stats models.StringStats
1775 IsStarred bool
1776 StarCount int
1777 Owner identity.Identity
1778 CommentList []models.CommentListItem
1779
1780 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData
1781 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool
1782 VouchRelationships map[syntax.DID]*models.VouchRelationship
1783}
1784
1785func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1786 return p.execute("strings/string", w, params)
1787}
1788
1789type SearchReposParams struct {
1790 BaseParams
1791 FilterType string // "repo" | "code"
1792 Repos []SearchResult
1793 Page pagination.Page
1794 ResultCount int
1795 FilterQuery string
1796 SortParam string
1797 TimeTaken time.Duration
1798 DocCount int64
1799 ErrorMsg string
1800}
1801
1802func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error {
1803 params.FilterType = "repo"
1804 return p.execute("search/search", w, params)
1805}
1806
1807type SearchQuickParams struct {
1808 Repos []models.Repo
1809 Query string
1810 Total int
1811}
1812
1813func (p *Pages) SearchQuick(w io.Writer, params SearchQuickParams) error {
1814 return p.executePlain("search/fragments/quick", w, params)
1815}
1816
1817func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error {
1818 tpl, err := p.parse("search/fragments/quick")
1819 if err != nil {
1820 return err
1821 }
1822 return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params)
1823}
1824
1825type SearchResult struct {
1826 RepoDID syntax.DID
1827 Repo *models.Repo
1828 FilePath string
1829 Branches []string
1830 Commit string
1831 Language string
1832
1833 File *CodeSearchResult_File // filename match
1834 Chunks CodeSearchResult_Chunks // content matches
1835}
1836
1837// CodeSearchResult_Chunk is a content match with its lines pre-rendered.
1838type CodeSearchResult_Chunk struct {
1839 Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges
1840 MatchCount int // number of match ranges in this chunk
1841}
1842
1843type CodeSearchResult_Chunks []CodeSearchResult_Chunk
1844
1845func (cs CodeSearchResult_Chunks) MatchCount() int {
1846 count := 0
1847 for _, c := range cs {
1848 count += c.MatchCount
1849 }
1850 return count
1851}
1852
1853type CodeSearchResult_File struct {
1854 NameSpans []ChunkSpan // precomputed from FilePath/Ranges
1855}
1856
1857type ChunkSpan struct {
1858 Text string
1859 Match bool
1860}
1861
1862type ChunkLine struct {
1863 Num int
1864 Spans []ChunkSpan
1865 Highlight bool
1866}
1867
1868// ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each
1869// line into matched/unmatched spans using ranges. startLine is the 1-based line
1870// number of the first line.
1871func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine {
1872 if startLine < 1 {
1873 startLine = 1
1874 }
1875 // trim a single trailing newline so we don't emit a spurious empty line
1876 content = strings.TrimSuffix(content, "\n")
1877 lines := strings.Split(content, "\n")
1878 out := make([]ChunkLine, len(lines))
1879 for i, text := range lines {
1880 num := startLine + i
1881 runes := []rune(text)
1882
1883 // collect matched rune intervals [c0,c1) for this line
1884 var intervals [][2]int
1885 for _, rg := range ranges {
1886 if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) {
1887 continue
1888 }
1889 c0, c1 := 0, len(runes)
1890 if num == int(rg.Start.LineNumber) {
1891 c0 = int(rg.Start.Column) - 1
1892 }
1893 if num == int(rg.End.LineNumber) {
1894 c1 = int(rg.End.Column) - 1
1895 }
1896 c0 = max(0, min(c0, len(runes)))
1897 c1 = max(0, min(c1, len(runes)))
1898 if c0 < c1 {
1899 intervals = append(intervals, [2]int{c0, c1})
1900 }
1901 }
1902 intervals = mergeIntervals(intervals)
1903
1904 out[i] = ChunkLine{
1905 Num: num,
1906 Spans: spanRunes(runes, intervals),
1907 Highlight: len(intervals) > 0,
1908 }
1909 }
1910 return out
1911}
1912
1913// FileNameSpans splits a filename into matched/unmatched spans using ranges.
1914// Filename ranges live on line 1; columns are clamped to rune bounds.
1915func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan {
1916 runes := []rune(name)
1917 var intervals [][2]int
1918 for _, rg := range ranges {
1919 if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 {
1920 continue
1921 }
1922 c0 := max(0, min(int(rg.Start.Column)-1, len(runes)))
1923 c1 := max(0, min(int(rg.End.Column)-1, len(runes)))
1924 if c0 < c1 {
1925 intervals = append(intervals, [2]int{c0, c1})
1926 }
1927 }
1928 return spanRunes(runes, mergeIntervals(intervals))
1929}
1930
1931type CodeSearchParams struct {
1932 BaseParams
1933 FilterType string // "code"
1934 FilterQuery string
1935 Results []SearchResult
1936 Page pagination.Page
1937 HasMore bool
1938 ErrorMsg string
1939
1940 MatchCount int
1941 FileCount int
1942 TimeTaken time.Duration
1943}
1944
1945func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error {
1946 params.FilterType = "code"
1947 return p.execute("search/search", w, params)
1948}
1949
1950func (p *Pages) Home(w io.Writer, params TimelineParams) error {
1951 return p.execute("timeline/home", w, params)
1952}
1953
1954type CommentBodyFragmentParams struct {
1955 Comment models.Comment
1956 Reactions map[models.ReactionKind]models.ReactionDisplayData
1957 UserReacted map[models.ReactionKind]bool
1958}
1959
1960func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error {
1961 return p.executePlain("fragments/comment/commentBody", w, params)
1962}
1963
1964type PullCommentFragmentParams struct {
1965 LoggedInUser *oauth.MultiAccountUser
1966 Comment models.Comment
1967 Reactions map[models.ReactionKind]models.ReactionDisplayData
1968 UserReacted map[models.ReactionKind]bool
1969 HxSwapOob bool
1970}
1971
1972func (p *Pages) PullCommentFragment(w io.Writer, params PullCommentFragmentParams) error {
1973 return p.executePlain("fragments/comment/pullComment", w, params)
1974}
1975
1976type CommentHeaderFragmentParams struct {
1977 Comment models.Comment
1978 Reactions map[models.ReactionKind]models.ReactionDisplayData
1979 UserReacted map[models.ReactionKind]bool
1980 HxSwapOob bool
1981}
1982
1983func (p *Pages) CommentHeaderFragment(w io.Writer, params CommentHeaderFragmentParams) error {
1984 return p.executePlain("fragments/comment/commentHeader", w, params)
1985}
1986
1987type EditCommentFragmentParams struct {
1988 Comment models.Comment
1989}
1990
1991func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error {
1992 return p.executePlain("fragments/comment/edit", w, params)
1993}
1994
1995type ReplyCommentFragmentParams struct {
1996 BaseParams
1997}
1998
1999func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error {
2000 return p.executePlain("fragments/comment/reply", w, params)
2001}
2002
2003type ReplyPlaceholderFragmentParams struct {
2004 BaseParams
2005}
2006
2007func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error {
2008 return p.executePlain("fragments/comment/replyPlaceholder", w, params)
2009}
2010
2011func (p *Pages) Static() http.Handler {
2012 if p.dev {
2013 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
2014 }
2015
2016 sub, err := fs.Sub(p.embedFS, "static")
2017 if err != nil {
2018 p.logger.Error("no static dir found? that's crazy", "err", err)
2019 panic(err)
2020 }
2021 // Custom handler to apply Cache-Control headers for font files
2022 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
2023}
2024
2025func Cache(h http.Handler) http.Handler {
2026 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2027 path := strings.Split(r.URL.Path, "?")[0]
2028
2029 if strings.HasSuffix(path, ".css") {
2030 // on day for css files
2031 w.Header().Set("Cache-Control", "public, max-age=86400")
2032 } else {
2033 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
2034 }
2035 h.ServeHTTP(w, r)
2036 })
2037}
2038
2039func (p *Pages) CssContentHash() string {
2040 cssFile, err := p.embedFS.Open("static/tw.css")
2041 if err != nil {
2042 slog.Debug("Error opening CSS file", "err", err)
2043 return ""
2044 }
2045 defer cssFile.Close()
2046
2047 hasher := sha256.New()
2048 if _, err := io.Copy(hasher, cssFile); err != nil {
2049 slog.Debug("Error hashing CSS file", "err", err)
2050 return ""
2051 }
2052
2053 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
2054}
2055
2056func (p *Pages) DangerPasswordTokenStep(w io.Writer) error {
2057 return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil)
2058}
2059
2060func (p *Pages) DangerPasswordSuccess(w io.Writer) error {
2061 return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil)
2062}
2063
2064func (p *Pages) DangerDeleteTokenStep(w io.Writer) error {
2065 return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil)
2066}
2067
2068func (p *Pages) Error500(w io.Writer) error {
2069 return p.execute("errors/500", w, nil)
2070}
2071
2072func (p *Pages) Error404(w io.Writer) error {
2073 return p.execute("errors/404", w, nil)
2074}
2075
2076func (p *Pages) ErrorKnot404(w io.Writer) error {
2077 return p.execute("errors/knot404", w, nil)
2078}
2079
2080func (p *Pages) Error503(w io.Writer) error {
2081 return p.execute("errors/503", w, nil)
2082}