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