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