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