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