feat(web): summary doc tabs, license and release badges on the index, keyboard fixes (!34)
- feat(web): document tabs and main-content navigation on the summary - fix(web): select the summary doc by exact file name first - feat(web): show cached repository licenses and latest release badges - fix(web): new activity pill expires after a minute on the index - feat(web): small refresh icon instead of the new activity pill, expires after a minute - style(web): CI mark alone on the index, state in the tooltip - style(web): smaller badge text - merge(web): combine summary docs and index badges - fix(web): refresh the index row on release events - fix(web): tell MIT-0 from MIT in license detection - test(web): mock the activity timer in the a11y test - test(web): summary golden with the labelled document card and no rail cursor - chore(web): mark renderDoc as display-only for hexcheck - style(web): link pills without underline, lighter border on hover - fix(web): keep the row hover underline off the name pills - fix(web): count ids and entry overhead in the path and license cache budget
27 files · +1694 -728
M internal/adapters/web/ASSETS.md +1 -1
| @@ -19,7 +19,7 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 19 | 19 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | |
| 20 | 20 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | |
| 21 | 21 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | |
| 22 | - | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | | | |
| 22 | + | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `refresh-cw`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `refresh-cw`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | | | |
| 23 | 23 | | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | | | |
| 24 | 24 | | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | | | |
| 25 | 25 | | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | | | |
| @@ -19,7 +19,7 @@ content-hashed names. Nothing is fetched at runtime. | |||||
| 19 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | 19 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | ||
| 20 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | 20 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | ||
| 21 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | 21 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | ||
| 22 | - | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | | | 22 | + | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `refresh-cw`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `refresh-cw`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | | |
| 23 | | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | | | 23 | | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | | | ||
| 24 | | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | | | 24 | | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | | | ||
| 25 | | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | | | 25 | | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | | | ||
M internal/adapters/web/components.templ +27 -9
| @@ -41,11 +41,17 @@ templ HomeRowView(r HomeRow) { | |||
| 41 | 41 | <div class="repo-main"> | |
| 42 | 42 | <h2 class="repo-name"> | |
| 43 | 43 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| 44 | + | if rel := r.Release; rel != nil { | |
| 45 | + | <a class="pill repo-release" href={ templ.SafeURL(rel.Href) } title={ "Latest release: " + rel.Tag }>{ rel.Tag }</a> | |
| 46 | + | } | |
| 44 | 47 | if r.Private { | |
| 45 | 48 | <span class="pill">private</span> | |
| 46 | 49 | } | |
| 50 | + | if lic := r.License; lic != nil { | |
| 51 | + | <a class="pill repo-license" href={ templ.SafeURL(lic.Href) } title={ "License: " + lic.ID }>{ lic.ID }</a> | |
| 52 | + | } | |
| 47 | 53 | if r.Fresh { | |
| 48 | - | <span class="pill new">new activity</span> | |
| 54 | + | <span class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span> | |
| 49 | 55 | } | |
| 50 | 56 | </h2> | |
| 51 | 57 | if r.Desc != "" { | |
| @@ -94,9 +100,9 @@ templ HomeRowView(r HomeRow) { | |||
| 94 | 100 | } | |
| 95 | 101 | </dl> | |
| 96 | 102 | if r.CI != nil { | |
| 97 | - | <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }> | |
| 103 | + | <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Label + " · " + r.CI.Title }> | |
| 98 | 104 | <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span> | |
| 99 | - | <span class="sr">CI: </span><span class="ci-label">{ r.CI.Label }</span> | |
| 105 | + | <span class="sr">{ "CI: " + r.CI.Label }</span> | |
| 100 | 106 | </a> | |
| 101 | 107 | } | |
| 102 | 108 | </div> | |
| @@ -153,12 +159,24 @@ templ Outline(headings []Heading) { | |||
| 153 | 159 | } | |
| 154 | 160 | ||
| 155 | 161 | // ReadmeCard is a README in a card whose header names the file. | |
| 156 | - | templ ReadmeCard(r Readme) { | |
| 157 | - | <section class="card readme" aria-labelledby="readme-h"> | |
| 158 | - | <h2 class="card-h" id="readme-h"> | |
| 159 | - | <span class="ico i-file" aria-hidden="true"></span> | |
| 160 | - | { r.Name } | |
| 161 | - | </h2> | |
| 162 | + | templ ReadmeCard(r Readme, docs ...DocTab) { | |
| 163 | + | <section class="card readme" aria-label={ r.Name }> | |
| 164 | + | if len(docs) > 1 { | |
| 165 | + | <nav class="card-h doc-tabs" aria-label="Repository documents"> | |
| 166 | + | for _, doc := range docs { | |
| 167 | + | <a href={ templ.SafeURL(doc.Href) } | |
| 168 | + | if doc.Current { | |
| 169 | + | aria-current="page" | |
| 170 | + | } | |
| 171 | + | >{ doc.Name }</a> | |
| 172 | + | } | |
| 173 | + | </nav> | |
| 174 | + | } else { | |
| 175 | + | <h2 class="card-h" id="readme-h"> | |
| 176 | + | <span class="ico i-file" aria-hidden="true"></span> | |
| 177 | + | { r.Name } | |
| 178 | + | </h2> | |
| 179 | + | } | |
| 162 | 180 | @ReadmeView(r) | |
| 163 | 181 | </section> | |
| 164 | 182 | } | |
| @@ -41,11 +41,17 @@ templ HomeRowView(r HomeRow) { | |||||
| 41 | <div class="repo-main"> | 41 | <div class="repo-main"> | ||
| 42 | <h2 class="repo-name"> | 42 | <h2 class="repo-name"> | ||
| 43 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | 43 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | ||
| 44 | + | if rel := r.Release; rel != nil { | |||
| 45 | + | <a class="pill repo-release" href={ templ.SafeURL(rel.Href) } title={ "Latest release: " + rel.Tag }>{ rel.Tag }</a> | |||
| 46 | + | } | |||
| 44 | if r.Private { | 47 | if r.Private { | ||
| 45 | <span class="pill">private</span> | 48 | <span class="pill">private</span> | ||
| 46 | } | 49 | } | ||
| 50 | + | if lic := r.License; lic != nil { | |||
| 51 | + | <a class="pill repo-license" href={ templ.SafeURL(lic.Href) } title={ "License: " + lic.ID }>{ lic.ID }</a> | |||
| 52 | + | } | |||
| 47 | if r.Fresh { | 53 | if r.Fresh { | ||
| 48 | - | <span class="pill new">new activity</span> | 54 | + | <span class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span> |
| 49 | } | 55 | } | ||
| 50 | </h2> | 56 | </h2> | ||
| 51 | if r.Desc != "" { | 57 | if r.Desc != "" { | ||
| @@ -94,9 +100,9 @@ templ HomeRowView(r HomeRow) { | |||||
| 94 | } | 100 | } | ||
| 95 | </dl> | 101 | </dl> | ||
| 96 | if r.CI != nil { | 102 | if r.CI != nil { | ||
| 97 | - | <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }> | 103 | + | <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Label + " · " + r.CI.Title }> |
| 98 | <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span> | 104 | <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span> | ||
| 99 | - | <span class="sr">CI: </span><span class="ci-label">{ r.CI.Label }</span> | 105 | + | <span class="sr">{ "CI: " + r.CI.Label }</span> |
| 100 | </a> | 106 | </a> | ||
| 101 | } | 107 | } | ||
| 102 | </div> | 108 | </div> | ||
| @@ -153,12 +159,24 @@ templ Outline(headings []Heading) { | |||||
| 153 | } | 159 | } | ||
| 154 | 160 | ||||
| 155 | // ReadmeCard is a README in a card whose header names the file. | 161 | // ReadmeCard is a README in a card whose header names the file. | ||
| 156 | - | templ ReadmeCard(r Readme) { | 162 | + | templ ReadmeCard(r Readme, docs ...DocTab) { |
| 157 | - | <section class="card readme" aria-labelledby="readme-h"> | 163 | + | <section class="card readme" aria-label={ r.Name }> |
| 158 | - | <h2 class="card-h" id="readme-h"> | 164 | + | if len(docs) > 1 { |
| 159 | - | <span class="ico i-file" aria-hidden="true"></span> | 165 | + | <nav class="card-h doc-tabs" aria-label="Repository documents"> |
| 160 | - | { r.Name } | 166 | + | for _, doc := range docs { |
| 161 | - | </h2> | 167 | + | <a href={ templ.SafeURL(doc.Href) } |
| 168 | + | if doc.Current { | |||
| 169 | + | aria-current="page" | |||
| 170 | + | } | |||
| 171 | + | >{ doc.Name }</a> | |||
| 172 | + | } | |||
| 173 | + | </nav> | |||
| 174 | + | } else { | |||
| 175 | + | <h2 class="card-h" id="readme-h"> | |||
| 176 | + | <span class="ico i-file" aria-hidden="true"></span> | |||
| 177 | + | { r.Name } | |||
| 178 | + | </h2> | |||
| 179 | + | } | |||
| 162 | @ReadmeView(r) | 180 | @ReadmeView(r) | ||
| 163 | </section> | 181 | </section> | ||
| 164 | } | 182 | } | ||
M internal/adapters/web/components_templ.go +504 -344 generated file: collapsed
M internal/adapters/web/fragments.go +8 -0
| @@ -135,6 +135,14 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, | |||
| 135 | 135 | name, fresh = ev.Issue.Repo.String(), true | |
| 136 | 136 | case domain.IssueCommented: | |
| 137 | 137 | name, fresh = ev.Repo.String(), true | |
| 138 | + | case domain.ReleasePublished: | |
| 139 | + | name = ev.Release.Repo.String() | |
| 140 | + | case domain.ReleaseUpdated: | |
| 141 | + | name = ev.Release.Repo.String() | |
| 142 | + | case domain.ReleaseDeleted: | |
| 143 | + | name = ev.Release.Repo.String() | |
| 144 | + | case domain.ReleaseUnpublished: | |
| 145 | + | name = ev.Release.Repo.String() | |
| 138 | 146 | case domain.RunUpdated: | |
| 139 | 147 | name = ev.Run.Repo.String() // the CI state of the row | |
| 140 | 148 | case domain.JobUpdated: | |
| @@ -135,6 +135,14 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, | |||||
| 135 | name, fresh = ev.Issue.Repo.String(), true | 135 | name, fresh = ev.Issue.Repo.String(), true | ||
| 136 | case domain.IssueCommented: | 136 | case domain.IssueCommented: | ||
| 137 | name, fresh = ev.Repo.String(), true | 137 | name, fresh = ev.Repo.String(), true | ||
| 138 | + | case domain.ReleasePublished: | |||
| 139 | + | name = ev.Release.Repo.String() | |||
| 140 | + | case domain.ReleaseUpdated: | |||
| 141 | + | name = ev.Release.Repo.String() | |||
| 142 | + | case domain.ReleaseDeleted: | |||
| 143 | + | name = ev.Release.Repo.String() | |||
| 144 | + | case domain.ReleaseUnpublished: | |||
| 145 | + | name = ev.Release.Repo.String() | |||
| 138 | case domain.RunUpdated: | 146 | case domain.RunUpdated: | ||
| 139 | name = ev.Run.Repo.String() // the CI state of the row | 147 | name = ev.Run.Repo.String() // the CI state of the row | ||
| 140 | case domain.JobUpdated: | 148 | case domain.JobUpdated: | ||
M internal/adapters/web/home.go +2 -0
| @@ -162,6 +162,7 @@ func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time. | |||
| 162 | 162 | // the newest commits of its default branch and the state of its latest run. | |
| 163 | 163 | func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow { | |
| 164 | 164 | row := homeRow(r, now, cursor, SignedIn(ctx)) | |
| 165 | + | row.Release = s.summaryRelease(ctx, r.Name, now) | |
| 165 | 166 | if r.PushedAt.IsZero() || r.DefaultBranch == "" { | |
| 166 | 167 | row.NoCommits = r.PushedAt.IsZero() | |
| 167 | 168 | return row | |
| @@ -169,6 +170,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, | |||
| 169 | 170 | var head string | |
| 170 | 171 | row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now) | |
| 171 | 172 | if head != "" { | |
| 173 | + | row.License = s.repositoryLicense(ctx, r.Name, head) | |
| 172 | 174 | row.CI = s.homeCI(ctx, r, head) | |
| 173 | 175 | row.Stats = s.homeStats(ctx, r, head, now) | |
| 174 | 176 | } | |
| @@ -162,6 +162,7 @@ func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time. | |||||
| 162 | // the newest commits of its default branch and the state of its latest run. | 162 | // the newest commits of its default branch and the state of its latest run. | ||
| 163 | func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow { | 163 | func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow { | ||
| 164 | row := homeRow(r, now, cursor, SignedIn(ctx)) | 164 | row := homeRow(r, now, cursor, SignedIn(ctx)) | ||
| 165 | + | row.Release = s.summaryRelease(ctx, r.Name, now) | |||
| 165 | if r.PushedAt.IsZero() || r.DefaultBranch == "" { | 166 | if r.PushedAt.IsZero() || r.DefaultBranch == "" { | ||
| 166 | row.NoCommits = r.PushedAt.IsZero() | 167 | row.NoCommits = r.PushedAt.IsZero() | ||
| 167 | return row | 168 | return row | ||
| @@ -169,6 +170,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, | |||||
| 169 | var head string | 170 | var head string | ||
| 170 | row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now) | 171 | row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now) | ||
| 171 | if head != "" { | 172 | if head != "" { | ||
| 173 | + | row.License = s.repositoryLicense(ctx, r.Name, head) | |||
| 172 | row.CI = s.homeCI(ctx, r, head) | 174 | row.CI = s.homeCI(ctx, r, head) | ||
| 173 | row.Stats = s.homeStats(ctx, r, head, now) | 175 | row.Stats = s.homeStats(ctx, r, head, now) | ||
| 174 | } | 176 | } | ||
M internal/adapters/web/home_test.go +81 -2
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "context" | |
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | + | "html" | |
| 10 | 11 | "net/http" | |
| 11 | 12 | "net/http/httptest" | |
| 12 | 13 | "strings" | |
| @@ -50,6 +51,7 @@ func homeCommits(t testing.TB, n int) []domain.Commit { | |||
| 50 | 51 | func homeBrowse(t *testing.T) *mocks.BrowseService { | |
| 51 | 52 | t.Helper() | |
| 52 | 53 | b := mocks.NewBrowseService(t) | |
| 54 | + | b.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe() | |
| 53 | 55 | b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 54 | 56 | Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe() | |
| 55 | 57 | return b | |
| @@ -63,6 +65,7 @@ func TestHomeRowFigures(t *testing.T) { | |||
| 63 | 65 | head := objectID(t, fmt.Sprintf("%040x", 2)) | |
| 64 | 66 | now := time.Now() | |
| 65 | 67 | browse := mocks.NewBrowseService(t) | |
| 68 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe() | |
| 66 | 69 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | |
| 67 | 70 | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{ | |
| 68 | 71 | Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head}, | |
| @@ -212,8 +215,10 @@ func TestHomeRowCI(t *testing.T) { | |||
| 212 | 215 | } | |
| 213 | 216 | require.NotNil(t, row.CI) | |
| 214 | 217 | assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`) | |
| 215 | - | assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it") | |
| 216 | - | assert.Contains(t, out, `<span class="sr">CI: </span>`) | |
| 218 | + | assert.Contains(t, out, `<span class="sr">CI: `+tt.label+`</span>`) | |
| 219 | + | assert.NotContains(t, out, `class="ci-label"`) | |
| 220 | + | assert.Contains(t, out, `title="`+html.EscapeString(tt.label+" · "+row.CI.Title)+`"`) | |
| 221 | + | assert.Contains(t, out, `class="ck ck-`+row.CI.Kind+`" aria-hidden="true"`) | |
| 217 | 222 | }) | |
| 218 | 223 | } | |
| 219 | 224 | } | |
| @@ -393,3 +398,77 @@ func TestActivityEventsRefreshTheHomeRow(t *testing.T) { | |||
| 393 | 398 | assert.Empty(t, got) | |
| 394 | 399 | } | |
| 395 | 400 | } | |
| 401 | + | ||
| 402 | + | func TestHomeReleaseAndLicensePills(t *testing.T) { | |
| 403 | + | t.Parallel() | |
| 404 | + | for _, tc := range []struct { | |
| 405 | + | name string | |
| 406 | + | release domain.Release | |
| 407 | + | err error | |
| 408 | + | visible bool | |
| 409 | + | }{ | |
| 410 | + | {name: "official release", release: domain.Release{Tag: "v1.2.0", PublishedAt: fixedNow}, visible: true}, | |
| 411 | + | {name: "plain tag only", err: domain.ErrNotFound}, | |
| 412 | + | {name: "draft", release: domain.Release{Tag: "secret", Draft: true}}, | |
| 413 | + | {name: "unpublished", release: domain.Release{Tag: "pending"}}, | |
| 414 | + | } { | |
| 415 | + | t.Run(tc.name, func(t *testing.T) { | |
| 416 | + | t.Parallel() | |
| 417 | + | repo := publicRepo(t, "proj") | |
| 418 | + | repo.Visibility = domain.VisibilityPrivate | |
| 419 | + | svc := mocks.NewReleaseService(t) | |
| 420 | + | svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once() | |
| 421 | + | srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: mocks.NewBrowseService(t), Releases: svc}) | |
| 422 | + | row := srv.homeRowOf(t.Context(), repo, fixedNow, false) | |
| 423 | + | row.Private, row.Fresh = true, true | |
| 424 | + | row.License = &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"} | |
| 425 | + | out := renderRow(t, row) | |
| 426 | + | assert.Contains(t, out, `class="pill repo-license" href="/proj/blob/main/LICENSE" title="License: MIT">MIT</a>`) | |
| 427 | + | assert.Regexp(t, `(?s)>private</span>.*repo-license.*class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span></h2>`, out) | |
| 428 | + | if tc.visible { | |
| 429 | + | assert.Regexp(t, `(?s)>proj</a>.*class="pill repo-release".*>v1.2.0</a>.*>private</span>`, out) | |
| 430 | + | assert.Contains(t, out, `href="/proj/releases/tag/v1.2.0"`) | |
| 431 | + | } else { | |
| 432 | + | assert.NotContains(t, out, `repo-release`) | |
| 433 | + | } | |
| 434 | + | }) | |
| 435 | + | } | |
| 436 | + | out := renderRow(t, HomeRow{Name: "bare"}) | |
| 437 | + | assert.NotContains(t, out, `repo-license`) | |
| 438 | + | assert.NotContains(t, out, `repo-release`) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func TestReleaseEventsRefreshTheHomeRow(t *testing.T) { | |
| 442 | + | t.Parallel() | |
| 443 | + | repo := publicRepo(t, "proj") | |
| 444 | + | rel := domain.Release{Repo: repo.Name, Tag: "v2", PublishedAt: fixedNow} | |
| 445 | + | for _, tc := range []struct { | |
| 446 | + | event domain.Event | |
| 447 | + | latest domain.Release | |
| 448 | + | err error | |
| 449 | + | }{ | |
| 450 | + | {domain.ReleasePublished{Release: rel}, rel, nil}, | |
| 451 | + | {domain.ReleaseUpdated{Release: rel}, domain.Release{Tag: "v1", PublishedAt: fixedNow}, nil}, | |
| 452 | + | {domain.ReleaseDeleted{Release: rel}, domain.Release{}, domain.ErrNotFound}, | |
| 453 | + | {domain.ReleaseUnpublished{Release: rel}, domain.Release{}, domain.ErrNotFound}, | |
| 454 | + | } { | |
| 455 | + | t.Run(tc.event.EventKind(), func(t *testing.T) { | |
| 456 | + | t.Parallel() | |
| 457 | + | releases := mocks.NewReleaseService(t) | |
| 458 | + | releases.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.latest, tc.err).Once() | |
| 459 | + | srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t), Releases: releases}) | |
| 460 | + | got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: tc.event}) | |
| 461 | + | require.NoError(t, err) | |
| 462 | + | require.Len(t, got, 1) | |
| 463 | + | assert.Equal(t, "home-list", got[0].event) | |
| 464 | + | assert.Contains(t, got[0].html, `id="repo-proj"`) | |
| 465 | + | assert.NotContains(t, got[0].html, `data-activity`) | |
| 466 | + | assert.NotContains(t, got[0].html, `new activity`) | |
| 467 | + | if tc.err != nil { | |
| 468 | + | assert.NotContains(t, got[0].html, `repo-release`) | |
| 469 | + | } else { | |
| 470 | + | assert.Contains(t, got[0].html, `href="/proj/releases/tag/`+tc.latest.Tag+`"`) | |
| 471 | + | } | |
| 472 | + | }) | |
| 473 | + | } | |
| 474 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "context" | 7 | "context" | ||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | + | "html" | |||
| 10 | "net/http" | 11 | "net/http" | ||
| 11 | "net/http/httptest" | 12 | "net/http/httptest" | ||
| 12 | "strings" | 13 | "strings" | ||
| @@ -50,6 +51,7 @@ func homeCommits(t testing.TB, n int) []domain.Commit { | |||||
| 50 | func homeBrowse(t *testing.T) *mocks.BrowseService { | 51 | func homeBrowse(t *testing.T) *mocks.BrowseService { | ||
| 51 | t.Helper() | 52 | t.Helper() | ||
| 52 | b := mocks.NewBrowseService(t) | 53 | b := mocks.NewBrowseService(t) | ||
| 54 | + | b.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe() | |||
| 53 | b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | 55 | b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | ||
| 54 | Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe() | 56 | Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe() | ||
| 55 | return b | 57 | return b | ||
| @@ -63,6 +65,7 @@ func TestHomeRowFigures(t *testing.T) { | |||||
| 63 | head := objectID(t, fmt.Sprintf("%040x", 2)) | 65 | head := objectID(t, fmt.Sprintf("%040x", 2)) | ||
| 64 | now := time.Now() | 66 | now := time.Now() | ||
| 65 | browse := mocks.NewBrowseService(t) | 67 | browse := mocks.NewBrowseService(t) | ||
| 68 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe() | |||
| 66 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | 69 | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil) | ||
| 67 | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{ | 70 | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{ | ||
| 68 | Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head}, | 71 | Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head}, | ||
| @@ -212,8 +215,10 @@ func TestHomeRowCI(t *testing.T) { | |||||
| 212 | } | 215 | } | ||
| 213 | require.NotNil(t, row.CI) | 216 | require.NotNil(t, row.CI) | ||
| 214 | assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`) | 217 | assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`) | ||
| 215 | - | assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it") | 218 | + | assert.Contains(t, out, `<span class="sr">CI: `+tt.label+`</span>`) |
| 216 | - | assert.Contains(t, out, `<span class="sr">CI: </span>`) | 219 | + | assert.NotContains(t, out, `class="ci-label"`) |
| 220 | + | assert.Contains(t, out, `title="`+html.EscapeString(tt.label+" · "+row.CI.Title)+`"`) | |||
| 221 | + | assert.Contains(t, out, `class="ck ck-`+row.CI.Kind+`" aria-hidden="true"`) | |||
| 217 | }) | 222 | }) | ||
| 218 | } | 223 | } | ||
| 219 | } | 224 | } | ||
| @@ -393,3 +398,77 @@ func TestActivityEventsRefreshTheHomeRow(t *testing.T) { | |||||
| 393 | assert.Empty(t, got) | 398 | assert.Empty(t, got) | ||
| 394 | } | 399 | } | ||
| 395 | } | 400 | } | ||
| 401 | + | ||||
| 402 | + | func TestHomeReleaseAndLicensePills(t *testing.T) { | |||
| 403 | + | t.Parallel() | |||
| 404 | + | for _, tc := range []struct { | |||
| 405 | + | name string | |||
| 406 | + | release domain.Release | |||
| 407 | + | err error | |||
| 408 | + | visible bool | |||
| 409 | + | }{ | |||
| 410 | + | {name: "official release", release: domain.Release{Tag: "v1.2.0", PublishedAt: fixedNow}, visible: true}, | |||
| 411 | + | {name: "plain tag only", err: domain.ErrNotFound}, | |||
| 412 | + | {name: "draft", release: domain.Release{Tag: "secret", Draft: true}}, | |||
| 413 | + | {name: "unpublished", release: domain.Release{Tag: "pending"}}, | |||
| 414 | + | } { | |||
| 415 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 416 | + | t.Parallel() | |||
| 417 | + | repo := publicRepo(t, "proj") | |||
| 418 | + | repo.Visibility = domain.VisibilityPrivate | |||
| 419 | + | svc := mocks.NewReleaseService(t) | |||
| 420 | + | svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once() | |||
| 421 | + | srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: mocks.NewBrowseService(t), Releases: svc}) | |||
| 422 | + | row := srv.homeRowOf(t.Context(), repo, fixedNow, false) | |||
| 423 | + | row.Private, row.Fresh = true, true | |||
| 424 | + | row.License = &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"} | |||
| 425 | + | out := renderRow(t, row) | |||
| 426 | + | assert.Contains(t, out, `class="pill repo-license" href="/proj/blob/main/LICENSE" title="License: MIT">MIT</a>`) | |||
| 427 | + | assert.Regexp(t, `(?s)>private</span>.*repo-license.*class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span></h2>`, out) | |||
| 428 | + | if tc.visible { | |||
| 429 | + | assert.Regexp(t, `(?s)>proj</a>.*class="pill repo-release".*>v1.2.0</a>.*>private</span>`, out) | |||
| 430 | + | assert.Contains(t, out, `href="/proj/releases/tag/v1.2.0"`) | |||
| 431 | + | } else { | |||
| 432 | + | assert.NotContains(t, out, `repo-release`) | |||
| 433 | + | } | |||
| 434 | + | }) | |||
| 435 | + | } | |||
| 436 | + | out := renderRow(t, HomeRow{Name: "bare"}) | |||
| 437 | + | assert.NotContains(t, out, `repo-license`) | |||
| 438 | + | assert.NotContains(t, out, `repo-release`) | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | func TestReleaseEventsRefreshTheHomeRow(t *testing.T) { | |||
| 442 | + | t.Parallel() | |||
| 443 | + | repo := publicRepo(t, "proj") | |||
| 444 | + | rel := domain.Release{Repo: repo.Name, Tag: "v2", PublishedAt: fixedNow} | |||
| 445 | + | for _, tc := range []struct { | |||
| 446 | + | event domain.Event | |||
| 447 | + | latest domain.Release | |||
| 448 | + | err error | |||
| 449 | + | }{ | |||
| 450 | + | {domain.ReleasePublished{Release: rel}, rel, nil}, | |||
| 451 | + | {domain.ReleaseUpdated{Release: rel}, domain.Release{Tag: "v1", PublishedAt: fixedNow}, nil}, | |||
| 452 | + | {domain.ReleaseDeleted{Release: rel}, domain.Release{}, domain.ErrNotFound}, | |||
| 453 | + | {domain.ReleaseUnpublished{Release: rel}, domain.Release{}, domain.ErrNotFound}, | |||
| 454 | + | } { | |||
| 455 | + | t.Run(tc.event.EventKind(), func(t *testing.T) { | |||
| 456 | + | t.Parallel() | |||
| 457 | + | releases := mocks.NewReleaseService(t) | |||
| 458 | + | releases.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.latest, tc.err).Once() | |||
| 459 | + | srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t), Releases: releases}) | |||
| 460 | + | got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: tc.event}) | |||
| 461 | + | require.NoError(t, err) | |||
| 462 | + | require.Len(t, got, 1) | |||
| 463 | + | assert.Equal(t, "home-list", got[0].event) | |||
| 464 | + | assert.Contains(t, got[0].html, `id="repo-proj"`) | |||
| 465 | + | assert.NotContains(t, got[0].html, `data-activity`) | |||
| 466 | + | assert.NotContains(t, got[0].html, `new activity`) | |||
| 467 | + | if tc.err != nil { | |||
| 468 | + | assert.NotContains(t, got[0].html, `repo-release`) | |||
| 469 | + | } else { | |||
| 470 | + | assert.Contains(t, got[0].html, `href="/proj/releases/tag/`+tc.latest.Tag+`"`) | |||
| 471 | + | } | |||
| 472 | + | }) | |||
| 473 | + | } | |||
| 474 | + | } | |||
M internal/adapters/web/jstest/a11y.test.mjs +2 -1
| @@ -339,7 +339,8 @@ test('the preference is read from storage when the page loads', async () => { | |||
| 339 | 339 | }); | |
| 340 | 340 | ||
| 341 | 341 | ||
| 342 | - | test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => { | |
| 342 | + | test('activity moves a home row up only in newest activity order, preserving focus and cursor', (t) => { | |
| 343 | + | t.mock.timers.enable({ apis: ['setTimeout'] }); | |
| 343 | 344 | for (const [sort, desc, activity, moves] of [ | |
| 344 | 345 | ['activity', 'true', true, true], | |
| 345 | 346 | ['activity', 'false', true, false], | |
| @@ -339,7 +339,8 @@ test('the preference is read from storage when the page loads', async () => { | |||||
| 339 | }); | 339 | }); | ||
| 340 | 340 | ||||
| 341 | 341 | ||||
| 342 | - | test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => { | 342 | + | test('activity moves a home row up only in newest activity order, preserving focus and cursor', (t) => { |
| 343 | + | t.mock.timers.enable({ apis: ['setTimeout'] }); | |||
| 343 | for (const [sort, desc, activity, moves] of [ | 344 | for (const [sort, desc, activity, moves] of [ | ||
| 344 | ['activity', 'true', true, true], | 345 | ['activity', 'true', true, true], | ||
| 345 | ['activity', 'false', true, false], | 346 | ['activity', 'false', true, false], | ||
M internal/adapters/web/jstest/live.test.mjs +49 -0
| @@ -79,3 +79,52 @@ test('replacement stream URL carries the last connection id, including id-only e | |||
| 79 | 79 | assert.equal(url.searchParams.get('topic'), 'repo:demo'); | |
| 80 | 80 | } finally { globalThis.location = saved; } | |
| 81 | 81 | }); | |
| 82 | + | ||
| 83 | + | test('fresh home rows expire after a minute; later swaps still prepend and get a full minute', (t) => { | |
| 84 | + | const { live } = setup(t); | |
| 85 | + | let current = null, next = null; | |
| 86 | + | let prepends = 0; | |
| 87 | + | const rows = { dataset: { sort: 'activity', desc: 'true' }, prepend(el) { | |
| 88 | + | prepends++; | |
| 89 | + | current = el; | |
| 90 | + | el.parentElement = this; | |
| 91 | + | } }; | |
| 92 | + | const fresh = () => { | |
| 93 | + | const attrs = new Set(['data-activity']); | |
| 94 | + | const pill = { removed: false, remove() { this.removed = true; } }; | |
| 95 | + | return { id: 'repo-demo', pill, parentElement: rows, | |
| 96 | + | querySelectorAll: () => [], | |
| 97 | + | hasAttribute: (name) => attrs.has(name), | |
| 98 | + | removeAttribute: (name) => attrs.delete(name), | |
| 99 | + | querySelector: (sel) => sel === '.repo-activity' && !pill.removed ? pill : null, | |
| 100 | + | replaceWith(el) { current = el; el.parentElement = rows; }, | |
| 101 | + | }; | |
| 102 | + | }; | |
| 103 | + | document.createElement = () => ({ set innerHTML(_) {}, content: { get children() { return [next]; } } }); | |
| 104 | + | document.getElementById = (id) => id === 'repo-rows' ? rows : current; | |
| 105 | + | document.querySelectorAll = () => []; | |
| 106 | + | ||
| 107 | + | next = fresh(); | |
| 108 | + | live.swap('first'); | |
| 109 | + | const first = current; | |
| 110 | + | assert.equal(prepends, 1, 'a newly added repository is prepended'); | |
| 111 | + | t.mock.timers.tick(30000); | |
| 112 | + | next = fresh(); | |
| 113 | + | live.swap('second'); | |
| 114 | + | const second = current; | |
| 115 | + | assert.equal(prepends, 2, 'later activity reorders the existing row'); | |
| 116 | + | t.mock.timers.tick(30000); | |
| 117 | + | assert.equal(first.pill.removed, true); | |
| 118 | + | assert.equal(second.pill.removed, false, 'the old timer cannot remove the new badge'); | |
| 119 | + | t.mock.timers.tick(29999); | |
| 120 | + | assert.equal(second.pill.removed, false); | |
| 121 | + | t.mock.timers.tick(1); | |
| 122 | + | assert.equal(second.pill.removed, true, 'removal also removes it from the accessibility tree'); | |
| 123 | + | assert.equal(second.hasAttribute('data-activity'), false); | |
| 124 | + | next = fresh(); | |
| 125 | + | live.swap('third'); | |
| 126 | + | assert.equal(prepends, 3, 'activity still reorders after the previous badge expired'); | |
| 127 | + | assert.equal(current.pill.removed, false); | |
| 128 | + | t.mock.timers.tick(60000); | |
| 129 | + | assert.equal(current.pill.removed, true); | |
| 130 | + | }); | |
| @@ -79,3 +79,52 @@ test('replacement stream URL carries the last connection id, including id-only e | |||||
| 79 | assert.equal(url.searchParams.get('topic'), 'repo:demo'); | 79 | assert.equal(url.searchParams.get('topic'), 'repo:demo'); | ||
| 80 | } finally { globalThis.location = saved; } | 80 | } finally { globalThis.location = saved; } | ||
| 81 | }); | 81 | }); | ||
| 82 | + | ||||
| 83 | + | test('fresh home rows expire after a minute; later swaps still prepend and get a full minute', (t) => { | |||
| 84 | + | const { live } = setup(t); | |||
| 85 | + | let current = null, next = null; | |||
| 86 | + | let prepends = 0; | |||
| 87 | + | const rows = { dataset: { sort: 'activity', desc: 'true' }, prepend(el) { | |||
| 88 | + | prepends++; | |||
| 89 | + | current = el; | |||
| 90 | + | el.parentElement = this; | |||
| 91 | + | } }; | |||
| 92 | + | const fresh = () => { | |||
| 93 | + | const attrs = new Set(['data-activity']); | |||
| 94 | + | const pill = { removed: false, remove() { this.removed = true; } }; | |||
| 95 | + | return { id: 'repo-demo', pill, parentElement: rows, | |||
| 96 | + | querySelectorAll: () => [], | |||
| 97 | + | hasAttribute: (name) => attrs.has(name), | |||
| 98 | + | removeAttribute: (name) => attrs.delete(name), | |||
| 99 | + | querySelector: (sel) => sel === '.repo-activity' && !pill.removed ? pill : null, | |||
| 100 | + | replaceWith(el) { current = el; el.parentElement = rows; }, | |||
| 101 | + | }; | |||
| 102 | + | }; | |||
| 103 | + | document.createElement = () => ({ set innerHTML(_) {}, content: { get children() { return [next]; } } }); | |||
| 104 | + | document.getElementById = (id) => id === 'repo-rows' ? rows : current; | |||
| 105 | + | document.querySelectorAll = () => []; | |||
| 106 | + | ||||
| 107 | + | next = fresh(); | |||
| 108 | + | live.swap('first'); | |||
| 109 | + | const first = current; | |||
| 110 | + | assert.equal(prepends, 1, 'a newly added repository is prepended'); | |||
| 111 | + | t.mock.timers.tick(30000); | |||
| 112 | + | next = fresh(); | |||
| 113 | + | live.swap('second'); | |||
| 114 | + | const second = current; | |||
| 115 | + | assert.equal(prepends, 2, 'later activity reorders the existing row'); | |||
| 116 | + | t.mock.timers.tick(30000); | |||
| 117 | + | assert.equal(first.pill.removed, true); | |||
| 118 | + | assert.equal(second.pill.removed, false, 'the old timer cannot remove the new badge'); | |||
| 119 | + | t.mock.timers.tick(29999); | |||
| 120 | + | assert.equal(second.pill.removed, false); | |||
| 121 | + | t.mock.timers.tick(1); | |||
| 122 | + | assert.equal(second.pill.removed, true, 'removal also removes it from the accessibility tree'); | |||
| 123 | + | assert.equal(second.hasAttribute('data-activity'), false); | |||
| 124 | + | next = fresh(); | |||
| 125 | + | live.swap('third'); | |||
| 126 | + | assert.equal(prepends, 3, 'activity still reorders after the previous badge expired'); | |||
| 127 | + | assert.equal(current.pill.removed, false); | |||
| 128 | + | t.mock.timers.tick(60000); | |||
| 129 | + | assert.equal(current.pill.removed, true); | |||
| 130 | + | }); | |||
M internal/adapters/web/jstest/rail.test.mjs +20 -0
| @@ -62,3 +62,23 @@ test('h outside the panel leaves the focus alone; j / k outside it are not taken | |||
| 62 | 62 | assert.equal(active, main); | |
| 63 | 63 | assert.equal(R.step(1), false, 'the row cursor keeps j / k'); | |
| 64 | 64 | }); | |
| 65 | + | ||
| 66 | + | test('j / k cross section and recent-commit table boundaries in document order', () => { | |
| 67 | + | const controls = ['clone', 'about', 'commit-1', 'commit-2', 'actions', 'outline'].map((id) => el('a', { id, attrs: { href: '#' + id } })); | |
| 68 | + | for (const e of controls) e.focus = () => { active = e; }; | |
| 69 | + | rail.append( | |
| 70 | + | el('section', {}, controls[0]), | |
| 71 | + | el('section', {}, controls[1]), | |
| 72 | + | el('section', {}, el('table', {}, el('tbody', { attrs: { 'data-rows': '' } }, el('tr', {}, controls[2]), el('tr', {}, controls[3])))), | |
| 73 | + | el('section', {}, controls[4]), | |
| 74 | + | el('nav', {}, controls[5]), | |
| 75 | + | ); | |
| 76 | + | active = close; | |
| 77 | + | for (const e of controls) { assert.equal(R.step(1), true); assert.equal(active, e); } | |
| 78 | + | R.step(1); | |
| 79 | + | assert.equal(active, controls.at(-1)); | |
| 80 | + | for (const e of controls.slice(0, -1).reverse()) { R.step(-1); assert.equal(active, e); } | |
| 81 | + | R.leave(); | |
| 82 | + | assert.equal(active, main); | |
| 83 | + | assert.equal(R.step(1), false); | |
| 84 | + | }); | |
| @@ -62,3 +62,23 @@ test('h outside the panel leaves the focus alone; j / k outside it are not taken | |||||
| 62 | assert.equal(active, main); | 62 | assert.equal(active, main); | ||
| 63 | assert.equal(R.step(1), false, 'the row cursor keeps j / k'); | 63 | assert.equal(R.step(1), false, 'the row cursor keeps j / k'); | ||
| 64 | }); | 64 | }); | ||
| 65 | + | ||||
| 66 | + | test('j / k cross section and recent-commit table boundaries in document order', () => { | |||
| 67 | + | const controls = ['clone', 'about', 'commit-1', 'commit-2', 'actions', 'outline'].map((id) => el('a', { id, attrs: { href: '#' + id } })); | |||
| 68 | + | for (const e of controls) e.focus = () => { active = e; }; | |||
| 69 | + | rail.append( | |||
| 70 | + | el('section', {}, controls[0]), | |||
| 71 | + | el('section', {}, controls[1]), | |||
| 72 | + | el('section', {}, el('table', {}, el('tbody', { attrs: { 'data-rows': '' } }, el('tr', {}, controls[2]), el('tr', {}, controls[3])))), | |||
| 73 | + | el('section', {}, controls[4]), | |||
| 74 | + | el('nav', {}, controls[5]), | |||
| 75 | + | ); | |||
| 76 | + | active = close; | |||
| 77 | + | for (const e of controls) { assert.equal(R.step(1), true); assert.equal(active, e); } | |||
| 78 | + | R.step(1); | |||
| 79 | + | assert.equal(active, controls.at(-1)); | |||
| 80 | + | for (const e of controls.slice(0, -1).reverse()) { R.step(-1); assert.equal(active, e); } | |||
| 81 | + | R.leave(); | |||
| 82 | + | assert.equal(active, main); | |||
| 83 | + | assert.equal(R.step(1), false); | |||
| 84 | + | }); | |||
A internal/adapters/web/jstest/summary-navigation.test.mjs +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | import assert from 'node:assert/strict'; | |
| 4 | + | import { readFileSync } from 'node:fs'; | |
| 5 | + | import { test } from 'node:test'; | |
| 6 | + | import { runInNewContext } from 'node:vm'; | |
| 7 | + | import { el, installDom } from './dom.mjs'; | |
| 8 | + | ||
| 9 | + | const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |
| 10 | + | ||
| 11 | + | test('main navigation ignores rail rows, but still drives main tables', () => { | |
| 12 | + | const doc = installDom(); | |
| 13 | + | const main = el('main', { id: 'main' }); | |
| 14 | + | const recent = el('tr'); | |
| 15 | + | const rail = el('aside', { cls: 'rail' }, el('tbody', { attrs: { 'data-rows': '' } }, recent)); | |
| 16 | + | doc.body.append(main, rail); | |
| 17 | + | const scrolls = []; | |
| 18 | + | main.scrollBy = (_, y) => scrolls.push(['main-by', y]); | |
| 19 | + | main.scrollTo = (_, y) => scrolls.push(['main-to', y]); | |
| 20 | + | const context = { | |
| 21 | + | window: { ermiteRail: { step: () => false } }, | |
| 22 | + | $: (s) => doc.querySelector(s), | |
| 23 | + | $$: (s) => { | |
| 24 | + | const child = s.startsWith('[data-rows] > ') ? s.slice('[data-rows] > '.length) : null; | |
| 25 | + | if (child) return [...doc.querySelectorAll('[data-rows]')].flatMap((table) => table.children.filter((row) => row.matches(child))); | |
| 26 | + | return [...doc.querySelectorAll(s)]; | |
| 27 | + | }, | |
| 28 | + | body: () => doc.body, | |
| 29 | + | flash() {}, setTimeout() {}, | |
| 30 | + | scrollBy: (_, y) => scrolls.push(['page-by', y]), | |
| 31 | + | scrollTo: (_, y) => scrolls.push(['page-to', y]), | |
| 32 | + | }; | |
| 33 | + | runInNewContext(source.slice(source.indexOf(' const visibleRows ='), source.indexOf(' window.ermite = ermite;')) + '\nwindow.ermite = ermite;', context); | |
| 34 | + | const E = context.window.ermite; | |
| 35 | + | E.move(1); E.move(-1); E.edge(false); E.edge(true); | |
| 36 | + | assert.deepEqual(scrolls, [['page-by', 180], ['main-by', 180], ['page-by', -180], ['main-by', -180], ['page-to', 0], ['main-to', 0], ['page-to', 1e9], ['main-to', 1e9]]); | |
| 37 | + | assert.equal(recent.hasAttribute('data-cursor'), false); | |
| 38 | + | const first = el('tr'); | |
| 39 | + | const last = el('tr'); | |
| 40 | + | main.append(el('tbody', { attrs: { 'data-rows': '' } }, first, last)); | |
| 41 | + | E.move(1); | |
| 42 | + | assert.equal(first.hasAttribute('data-cursor'), true); | |
| 43 | + | E.edge(true); | |
| 44 | + | assert.equal(last.hasAttribute('data-cursor'), true); | |
| 45 | + | assert.equal(first.hasAttribute('data-cursor'), false); | |
| 46 | + | assert.equal(recent.hasAttribute('data-cursor'), false); | |
| 47 | + | }); | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | import assert from 'node:assert/strict'; | |||
| 4 | + | import { readFileSync } from 'node:fs'; | |||
| 5 | + | import { test } from 'node:test'; | |||
| 6 | + | import { runInNewContext } from 'node:vm'; | |||
| 7 | + | import { el, installDom } from './dom.mjs'; | |||
| 8 | + | ||||
| 9 | + | const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |||
| 10 | + | ||||
| 11 | + | test('main navigation ignores rail rows, but still drives main tables', () => { | |||
| 12 | + | const doc = installDom(); | |||
| 13 | + | const main = el('main', { id: 'main' }); | |||
| 14 | + | const recent = el('tr'); | |||
| 15 | + | const rail = el('aside', { cls: 'rail' }, el('tbody', { attrs: { 'data-rows': '' } }, recent)); | |||
| 16 | + | doc.body.append(main, rail); | |||
| 17 | + | const scrolls = []; | |||
| 18 | + | main.scrollBy = (_, y) => scrolls.push(['main-by', y]); | |||
| 19 | + | main.scrollTo = (_, y) => scrolls.push(['main-to', y]); | |||
| 20 | + | const context = { | |||
| 21 | + | window: { ermiteRail: { step: () => false } }, | |||
| 22 | + | $: (s) => doc.querySelector(s), | |||
| 23 | + | $$: (s) => { | |||
| 24 | + | const child = s.startsWith('[data-rows] > ') ? s.slice('[data-rows] > '.length) : null; | |||
| 25 | + | if (child) return [...doc.querySelectorAll('[data-rows]')].flatMap((table) => table.children.filter((row) => row.matches(child))); | |||
| 26 | + | return [...doc.querySelectorAll(s)]; | |||
| 27 | + | }, | |||
| 28 | + | body: () => doc.body, | |||
| 29 | + | flash() {}, setTimeout() {}, | |||
| 30 | + | scrollBy: (_, y) => scrolls.push(['page-by', y]), | |||
| 31 | + | scrollTo: (_, y) => scrolls.push(['page-to', y]), | |||
| 32 | + | }; | |||
| 33 | + | runInNewContext(source.slice(source.indexOf(' const visibleRows ='), source.indexOf(' window.ermite = ermite;')) + '\nwindow.ermite = ermite;', context); | |||
| 34 | + | const E = context.window.ermite; | |||
| 35 | + | E.move(1); E.move(-1); E.edge(false); E.edge(true); | |||
| 36 | + | assert.deepEqual(scrolls, [['page-by', 180], ['main-by', 180], ['page-by', -180], ['main-by', -180], ['page-to', 0], ['main-to', 0], ['page-to', 1e9], ['main-to', 1e9]]); | |||
| 37 | + | assert.equal(recent.hasAttribute('data-cursor'), false); | |||
| 38 | + | const first = el('tr'); | |||
| 39 | + | const last = el('tr'); | |||
| 40 | + | main.append(el('tbody', { attrs: { 'data-rows': '' } }, first, last)); | |||
| 41 | + | E.move(1); | |||
| 42 | + | assert.equal(first.hasAttribute('data-cursor'), true); | |||
| 43 | + | E.edge(true); | |||
| 44 | + | assert.equal(last.hasAttribute('data-cursor'), true); | |||
| 45 | + | assert.equal(first.hasAttribute('data-cursor'), false); | |||
| 46 | + | assert.equal(recent.hasAttribute('data-cursor'), false); | |||
| 47 | + | }); | |||
A internal/adapters/web/license.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // LicenseBadge names the root license file and its display identifier. | |
| 16 | + | type LicenseBadge struct { | |
| 17 | + | ID, Href string | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | var licenseNames = []string{"license", "license.md", "license.txt", "licence", "licence.md", "licence.txt", "copying", "copying.md", "copying.txt"} | |
| 21 | + | ||
| 22 | + | // repositoryLicense caches both absence and detection by immutable head. The | |
| 23 | + | // caller has already authorized repository browsing. Failures are not cached. | |
| 24 | + | // A separate instance of the bounded immutable-result cache coalesces misses. | |
| 25 | + | func (s *Server) repositoryLicense(ctx context.Context, repo domain.RepoName, head string) *LicenseBadge { | |
| 26 | + | key := repo.String() + "\x00" + head | |
| 27 | + | body, err := s.licenses.load(ctx, repo.String(), key, func() ([]byte, error) { | |
| 28 | + | badge, err := s.detectLicense(ctx, repo, head) | |
| 29 | + | if err != nil { | |
| 30 | + | return nil, err | |
| 31 | + | } | |
| 32 | + | return json.Marshal(badge) | |
| 33 | + | }) | |
| 34 | + | if err != nil { | |
| 35 | + | log := zerowrap.FromCtx(ctx) | |
| 36 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("read the repository license") | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | var badge *LicenseBadge | |
| 40 | + | if err := json.Unmarshal(body, &badge); err != nil { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | return badge | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | //hexcheck:ignore suspicious-business-logic-in-adapter picks a root license file for display only | |
| 47 | + | func (s *Server) detectLicense(ctx context.Context, repo domain.RepoName, head string) (*LicenseBadge, error) { | |
| 48 | + | tree, err := s.getTree(ctx, repo, head, "", false) | |
| 49 | + | if err != nil { | |
| 50 | + | return nil, err | |
| 51 | + | } | |
| 52 | + | for _, name := range licenseNames { | |
| 53 | + | for _, entry := range tree.Entries { | |
| 54 | + | if (entry.Kind != domain.EntryFile && entry.Kind != domain.EntryExecutable) || !strings.EqualFold(name, entry.Name) { | |
| 55 | + | continue | |
| 56 | + | } | |
| 57 | + | body, binary, err := s.readBlob(ctx, repo, head, entry.Path, 64<<10) | |
| 58 | + | if err != nil { | |
| 59 | + | return nil, err | |
| 60 | + | } | |
| 61 | + | id := "Other" | |
| 62 | + | if !binary { | |
| 63 | + | id = licenseID(string(body)) | |
| 64 | + | } | |
| 65 | + | return &LicenseBadge{ID: id, Href: repoHref(repo.String(), "blob", head, entry.Path)}, nil | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | return nil, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // licenseID deliberately recognizes titles and characteristic grants, not | |
| 72 | + | // incidental mentions of other licenses in their full text. | |
| 73 | + | func licenseID(body string) string { | |
| 74 | + | for _, line := range strings.Split(body, "\n") { | |
| 75 | + | if i := strings.Index(strings.ToLower(line), "spdx-license-identifier:"); i >= 0 { | |
| 76 | + | id := strings.TrimSpace(line[i+len("spdx-license-identifier:"):]) | |
| 77 | + | id = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(id, "-->"), "*/")) | |
| 78 | + | if id != "" && len(id) <= 160 { | |
| 79 | + | return id | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | text := strings.ToLower(strings.Join(strings.Fields(body), " ")) | |
| 84 | + | title := text[:min(len(text), 600)] | |
| 85 | + | switch { | |
| 86 | + | case strings.Contains(title, "gnu affero general public license") && strings.Contains(title, "version 3"): | |
| 87 | + | return "AGPL-3.0" | |
| 88 | + | case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 3"): | |
| 89 | + | return "LGPL-3.0" | |
| 90 | + | case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 2.1"): | |
| 91 | + | return "LGPL-2.1" | |
| 92 | + | case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 3"): | |
| 93 | + | return "GPL-3.0" | |
| 94 | + | case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 2"): | |
| 95 | + | return "GPL-2.0" | |
| 96 | + | case strings.Contains(title, "apache license") && strings.Contains(title, "version 2.0"): | |
| 97 | + | return "Apache-2.0" | |
| 98 | + | case strings.Contains(title, "mozilla public license") && strings.Contains(title, "2.0"): | |
| 99 | + | return "MPL-2.0" | |
| 100 | + | case strings.Contains(text, "permission is hereby granted, free of charge") && strings.Contains(text, "the software is provided \"as is\""): | |
| 101 | + | if strings.Contains(text, "the above copyright notice and this permission notice shall be included in all copies or substantial portions of the software") { | |
| 102 | + | return "MIT" | |
| 103 | + | } | |
| 104 | + | return "MIT-0" | |
| 105 | + | case strings.Contains(text, "all advertising materials"): | |
| 106 | + | return "Other" | |
| 107 | + | case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "neither the name"): | |
| 108 | + | return "BSD-3-Clause" | |
| 109 | + | case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "this list of conditions"): | |
| 110 | + | return "BSD-2-Clause" | |
| 111 | + | case strings.Contains(text, "permission to use, copy, modify, and/or distribute this software for any purpose with or without fee"): | |
| 112 | + | return "ISC" | |
| 113 | + | } | |
| 114 | + | return "Other" | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // LicenseBadge names the root license file and its display identifier. | |||
| 16 | + | type LicenseBadge struct { | |||
| 17 | + | ID, Href string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | var licenseNames = []string{"license", "license.md", "license.txt", "licence", "licence.md", "licence.txt", "copying", "copying.md", "copying.txt"} | |||
| 21 | + | ||||
| 22 | + | // repositoryLicense caches both absence and detection by immutable head. The | |||
| 23 | + | // caller has already authorized repository browsing. Failures are not cached. | |||
| 24 | + | // A separate instance of the bounded immutable-result cache coalesces misses. | |||
| 25 | + | func (s *Server) repositoryLicense(ctx context.Context, repo domain.RepoName, head string) *LicenseBadge { | |||
| 26 | + | key := repo.String() + "\x00" + head | |||
| 27 | + | body, err := s.licenses.load(ctx, repo.String(), key, func() ([]byte, error) { | |||
| 28 | + | badge, err := s.detectLicense(ctx, repo, head) | |||
| 29 | + | if err != nil { | |||
| 30 | + | return nil, err | |||
| 31 | + | } | |||
| 32 | + | return json.Marshal(badge) | |||
| 33 | + | }) | |||
| 34 | + | if err != nil { | |||
| 35 | + | log := zerowrap.FromCtx(ctx) | |||
| 36 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("read the repository license") | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | var badge *LicenseBadge | |||
| 40 | + | if err := json.Unmarshal(body, &badge); err != nil { | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | return badge | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | //hexcheck:ignore suspicious-business-logic-in-adapter picks a root license file for display only | |||
| 47 | + | func (s *Server) detectLicense(ctx context.Context, repo domain.RepoName, head string) (*LicenseBadge, error) { | |||
| 48 | + | tree, err := s.getTree(ctx, repo, head, "", false) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return nil, err | |||
| 51 | + | } | |||
| 52 | + | for _, name := range licenseNames { | |||
| 53 | + | for _, entry := range tree.Entries { | |||
| 54 | + | if (entry.Kind != domain.EntryFile && entry.Kind != domain.EntryExecutable) || !strings.EqualFold(name, entry.Name) { | |||
| 55 | + | continue | |||
| 56 | + | } | |||
| 57 | + | body, binary, err := s.readBlob(ctx, repo, head, entry.Path, 64<<10) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return nil, err | |||
| 60 | + | } | |||
| 61 | + | id := "Other" | |||
| 62 | + | if !binary { | |||
| 63 | + | id = licenseID(string(body)) | |||
| 64 | + | } | |||
| 65 | + | return &LicenseBadge{ID: id, Href: repoHref(repo.String(), "blob", head, entry.Path)}, nil | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | return nil, nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // licenseID deliberately recognizes titles and characteristic grants, not | |||
| 72 | + | // incidental mentions of other licenses in their full text. | |||
| 73 | + | func licenseID(body string) string { | |||
| 74 | + | for _, line := range strings.Split(body, "\n") { | |||
| 75 | + | if i := strings.Index(strings.ToLower(line), "spdx-license-identifier:"); i >= 0 { | |||
| 76 | + | id := strings.TrimSpace(line[i+len("spdx-license-identifier:"):]) | |||
| 77 | + | id = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(id, "-->"), "*/")) | |||
| 78 | + | if id != "" && len(id) <= 160 { | |||
| 79 | + | return id | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | text := strings.ToLower(strings.Join(strings.Fields(body), " ")) | |||
| 84 | + | title := text[:min(len(text), 600)] | |||
| 85 | + | switch { | |||
| 86 | + | case strings.Contains(title, "gnu affero general public license") && strings.Contains(title, "version 3"): | |||
| 87 | + | return "AGPL-3.0" | |||
| 88 | + | case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 3"): | |||
| 89 | + | return "LGPL-3.0" | |||
| 90 | + | case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 2.1"): | |||
| 91 | + | return "LGPL-2.1" | |||
| 92 | + | case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 3"): | |||
| 93 | + | return "GPL-3.0" | |||
| 94 | + | case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 2"): | |||
| 95 | + | return "GPL-2.0" | |||
| 96 | + | case strings.Contains(title, "apache license") && strings.Contains(title, "version 2.0"): | |||
| 97 | + | return "Apache-2.0" | |||
| 98 | + | case strings.Contains(title, "mozilla public license") && strings.Contains(title, "2.0"): | |||
| 99 | + | return "MPL-2.0" | |||
| 100 | + | case strings.Contains(text, "permission is hereby granted, free of charge") && strings.Contains(text, "the software is provided \"as is\""): | |||
| 101 | + | if strings.Contains(text, "the above copyright notice and this permission notice shall be included in all copies or substantial portions of the software") { | |||
| 102 | + | return "MIT" | |||
| 103 | + | } | |||
| 104 | + | return "MIT-0" | |||
| 105 | + | case strings.Contains(text, "all advertising materials"): | |||
| 106 | + | return "Other" | |||
| 107 | + | case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "neither the name"): | |||
| 108 | + | return "BSD-3-Clause" | |||
| 109 | + | case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "this list of conditions"): | |||
| 110 | + | return "BSD-2-Clause" | |||
| 111 | + | case strings.Contains(text, "permission to use, copy, modify, and/or distribute this software for any purpose with or without fee"): | |||
| 112 | + | return "ISC" | |||
| 113 | + | } | |||
| 114 | + | return "Other" | |||
| 115 | + | } | |||
A internal/adapters/web/license_test.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func TestLicenseID(t *testing.T) { | |
| 22 | + | t.Parallel() | |
| 23 | + | for _, tc := range []struct{ text, want string }{ | |
| 24 | + | {"// SPDX-License-Identifier: MIT", "MIT"}, | |
| 25 | + | {"<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->", "Apache-2.0 OR MIT"}, | |
| 26 | + | {"/* SPDX-License-Identifier: GPL-3.0-or-later */", "GPL-3.0-or-later"}, | |
| 27 | + | {"GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007", "AGPL-3.0"}, | |
| 28 | + | {"GNU GENERAL PUBLIC LICENSE Version 3" + strings.Repeat(" padding", 100) + "GNU Affero General Public License version 3", "GPL-3.0"}, | |
| 29 | + | {"GNU GENERAL PUBLIC LICENSE Version 2", "GPL-2.0"}, | |
| 30 | + | {"GNU LESSER GENERAL PUBLIC LICENSE Version 2.1", "LGPL-2.1"}, | |
| 31 | + | {"GNU LESSER GENERAL PUBLIC LICENSE Version 3", "LGPL-3.0"}, | |
| 32 | + | {"Apache License\nVersion 2.0, January 2004", "Apache-2.0"}, | |
| 33 | + | {"Mozilla Public License Version 2.0", "MPL-2.0"}, | |
| 34 | + | {"Permission is hereby granted, free of charge\nTHE SOFTWARE IS PROVIDED \"AS IS\"", "MIT-0"}, | |
| 35 | + | {"Redistribution and use in source and binary forms ... neither the name", "BSD-3-Clause"}, | |
| 36 | + | {"Redistribution and use in source and binary forms ... this list of conditions", "BSD-2-Clause"}, | |
| 37 | + | {"Redistribution and use in source and binary forms ... all advertising materials ... neither the name", "Other"}, | |
| 38 | + | {"Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee", "ISC"}, | |
| 39 | + | {"Custom license mentioning MIT or Apache-2.0", "Other"}, | |
| 40 | + | {"", "Other"}, | |
| 41 | + | } { | |
| 42 | + | t.Run(tc.want+tc.text[:min(20, len(tc.text))], func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestRepositoryLicenseCache(t *testing.T) { | |
| 47 | + | t.Parallel() | |
| 48 | + | for _, filename := range append(append([]string{}, licenseNames...), "LiCeNsE.MD", "missing", "directory") { | |
| 49 | + | t.Run(filename, func(t *testing.T) { | |
| 50 | + | t.Parallel() | |
| 51 | + | browse := mocks.NewBrowseService(t) | |
| 52 | + | repo := repoName(t, "proj") | |
| 53 | + | head := strings.Repeat("a", 40) | |
| 54 | + | entry := domain.TreeEntry{Name: filename, Path: filename, Kind: domain.EntryFile} | |
| 55 | + | if filename == "directory" { | |
| 56 | + | entry.Name, entry.Kind = "LICENSE", domain.EntryDirectory | |
| 57 | + | } | |
| 58 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{entry}}, nil).Once() | |
| 59 | + | found := filename != "missing" && filename != "directory" | |
| 60 | + | if found { | |
| 61 | + | browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("SPDX-License-Identifier: MIT")), nil).Once() | |
| 62 | + | } | |
| 63 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse}) | |
| 64 | + | for range 2 { | |
| 65 | + | badge := srv.repositoryLicense(t.Context(), repo, head) | |
| 66 | + | if !found { | |
| 67 | + | assert.Nil(t, badge) | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | require.NotNil(t, badge) | |
| 71 | + | assert.Equal(t, "MIT", badge.ID) | |
| 72 | + | assert.Equal(t, "/proj/blob/"+head+"/"+filename, badge.Href) | |
| 73 | + | } | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestRepositoryLicenseRetriesErrorsAndNewHeads(t *testing.T) { | |
| 79 | + | t.Parallel() | |
| 80 | + | browse := mocks.NewBrowseService(t) | |
| 81 | + | repo := repoName(t, "proj") | |
| 82 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, errors.New("unavailable")).Once() | |
| 83 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Twice() | |
| 84 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse}) | |
| 85 | + | for _, head := range []string{strings.Repeat("a", 40), strings.Repeat("a", 40), strings.Repeat("b", 40)} { | |
| 86 | + | assert.Nil(t, srv.repositoryLicense(context.Background(), repo, head)) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestSummaryLicense(t *testing.T) { | |
| 91 | + | t.Parallel() | |
| 92 | + | assert.NotContains(t, renderRail(t, SummaryPage{}), "<dt>license</dt>") | |
| 93 | + | out := renderRail(t, SummaryPage{License: &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}}) | |
| 94 | + | assert.Contains(t, out, `<dt>license</dt><dd><a href="/proj/blob/main/LICENSE">MIT</a></dd>`) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestHomeLicenseFromDefaultHead(t *testing.T) { | |
| 98 | + | t.Parallel() | |
| 99 | + | for _, binary := range []bool{false, true} { | |
| 100 | + | name := "unknown text" | |
| 101 | + | if binary { | |
| 102 | + | name = "binary" | |
| 103 | + | } | |
| 104 | + | t.Run(name, func(t *testing.T) { | |
| 105 | + | t.Parallel() | |
| 106 | + | browse := mocks.NewBrowseService(t) | |
| 107 | + | repo := pushedRepo(t, "proj") | |
| 108 | + | commits := homeCommits(t, 1) | |
| 109 | + | head := commits[0].ID.String() | |
| 110 | + | revision, err := domain.ParseRevision(head) | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo.Name, mock.Anything).Return(ports.HistoryPage{Commits: commits}, nil).Once() | |
| 113 | + | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo.Name, commits[0].ID, mock.Anything).Return(ports.RepoStats{}, nil).Once() | |
| 114 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{{Name: "COPYING", Path: "COPYING", Kind: domain.EntryFile}}}, nil).Once() | |
| 115 | + | browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything).Return(domain.BlobInfo{Binary: binary}, io.NopCloser(strings.NewReader("A custom license")), nil).Once() | |
| 116 | + | srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: browse}) | |
| 117 | + | row := srv.homeRowOf(t.Context(), repo, fixedNow, false) | |
| 118 | + | require.NotNil(t, row.License) | |
| 119 | + | assert.Equal(t, "Other", row.License.ID) | |
| 120 | + | assert.Contains(t, renderRow(t, row), `href="/proj/blob/`+head+`/COPYING" title="License: Other">Other</a>`) | |
| 121 | + | assert.Contains(t, renderRail(t, SummaryPage{License: row.License}), `<dt>license</dt><dd><a href="/proj/blob/`+head+`/COPYING">Other</a></dd>`) | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestLicenseFullTexts(t *testing.T) { | |
| 127 | + | t.Parallel() | |
| 128 | + | const grant = `Copyright (c) 2026 Example | |
| 129 | + | ||
| 130 | + | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 131 | + | of this software and associated documentation files (the "Software"), to deal | |
| 132 | + | in the Software without restriction, including without limitation the rights | |
| 133 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 134 | + | copies of the Software, and to permit persons to whom the Software is | |
| 135 | + | furnished to do so.` | |
| 136 | + | const notice = `The above copyright notice and this permission notice shall be included in | |
| 137 | + | all copies or substantial portions of the Software.` | |
| 138 | + | const warranty = `THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 139 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 140 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 141 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 142 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 143 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |
| 144 | + | THE SOFTWARE.` | |
| 145 | + | const bsd = `Copyright (c) 2026 Example. All rights reserved. | |
| 146 | + | Redistribution and use in source and binary forms, with or without | |
| 147 | + | modification, are permitted provided that the following conditions are met: | |
| 148 | + | 1. Redistributions of source code must retain the above copyright notice, | |
| 149 | + | this list of conditions and the following disclaimer. | |
| 150 | + | 2. Redistributions in binary form must reproduce the above copyright notice, | |
| 151 | + | this list of conditions and the following disclaimer in the documentation | |
| 152 | + | and/or other materials provided with the distribution.` | |
| 153 | + | const endorsement = `3. Neither the name of the copyright holder nor the names of its | |
| 154 | + | contributors may be used to endorse or promote products derived from | |
| 155 | + | this software without specific prior written permission.` | |
| 156 | + | const disclaimer = `THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | |
| 157 | + | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | |
| 158 | + | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE | |
| 159 | + | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE | |
| 160 | + | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR | |
| 161 | + | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF | |
| 162 | + | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS | |
| 163 | + | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN | |
| 164 | + | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) | |
| 165 | + | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE | |
| 166 | + | POSSIBILITY OF SUCH DAMAGE.` | |
| 167 | + | const gnu = `Copyright (C) 2007 Free Software Foundation, Inc. | |
| 168 | + | Everyone is permitted to copy and distribute verbatim copies of this license | |
| 169 | + | document, but changing it is not allowed. | |
| 170 | + | ||
| 171 | + | Preamble | |
| 172 | + | The licenses for most software and other practical works are designed to | |
| 173 | + | take away your freedom to share and change the works. By contrast, the GNU | |
| 174 | + | General Public License is intended to guarantee your freedom to share and | |
| 175 | + | change all versions of a program--to make sure it remains free software | |
| 176 | + | for all its users.` | |
| 177 | + | for _, tc := range []struct{ name, text, want string }{ | |
| 178 | + | {"MIT", "MIT License\n" + grant + "\n" + notice + "\n" + warranty, "MIT"}, | |
| 179 | + | {"MIT-0", "MIT No Attribution\n" + grant + "\n" + warranty, "MIT-0"}, | |
| 180 | + | {"BSD-2", bsd + "\n" + disclaimer, "BSD-2-Clause"}, | |
| 181 | + | {"BSD-3", bsd + "\n" + endorsement + "\n" + disclaimer, "BSD-3-Clause"}, | |
| 182 | + | {"GPL", "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + strings.Repeat("\nTerms and conditions for copying, distribution and modification.", 10) + "\nUse with the GNU Affero General Public License version 3.", "GPL-3.0"}, | |
| 183 | + | {"LGPL", "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + "\nThis version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by additional permissions.", "LGPL-3.0"}, | |
| 184 | + | {"AGPL", "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007\n" + gnu + "\nNotwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network an opportunity to receive the Corresponding Source.", "AGPL-3.0"}, | |
| 185 | + | } { | |
| 186 | + | t.Run(tc.name, func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) }) | |
| 187 | + | } | |
| 188 | + | } | |
| @@ -0,0 +1,188 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func TestLicenseID(t *testing.T) { | |||
| 22 | + | t.Parallel() | |||
| 23 | + | for _, tc := range []struct{ text, want string }{ | |||
| 24 | + | {"// SPDX-License-Identifier: MIT", "MIT"}, | |||
| 25 | + | {"<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->", "Apache-2.0 OR MIT"}, | |||
| 26 | + | {"/* SPDX-License-Identifier: GPL-3.0-or-later */", "GPL-3.0-or-later"}, | |||
| 27 | + | {"GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007", "AGPL-3.0"}, | |||
| 28 | + | {"GNU GENERAL PUBLIC LICENSE Version 3" + strings.Repeat(" padding", 100) + "GNU Affero General Public License version 3", "GPL-3.0"}, | |||
| 29 | + | {"GNU GENERAL PUBLIC LICENSE Version 2", "GPL-2.0"}, | |||
| 30 | + | {"GNU LESSER GENERAL PUBLIC LICENSE Version 2.1", "LGPL-2.1"}, | |||
| 31 | + | {"GNU LESSER GENERAL PUBLIC LICENSE Version 3", "LGPL-3.0"}, | |||
| 32 | + | {"Apache License\nVersion 2.0, January 2004", "Apache-2.0"}, | |||
| 33 | + | {"Mozilla Public License Version 2.0", "MPL-2.0"}, | |||
| 34 | + | {"Permission is hereby granted, free of charge\nTHE SOFTWARE IS PROVIDED \"AS IS\"", "MIT-0"}, | |||
| 35 | + | {"Redistribution and use in source and binary forms ... neither the name", "BSD-3-Clause"}, | |||
| 36 | + | {"Redistribution and use in source and binary forms ... this list of conditions", "BSD-2-Clause"}, | |||
| 37 | + | {"Redistribution and use in source and binary forms ... all advertising materials ... neither the name", "Other"}, | |||
| 38 | + | {"Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee", "ISC"}, | |||
| 39 | + | {"Custom license mentioning MIT or Apache-2.0", "Other"}, | |||
| 40 | + | {"", "Other"}, | |||
| 41 | + | } { | |||
| 42 | + | t.Run(tc.want+tc.text[:min(20, len(tc.text))], func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestRepositoryLicenseCache(t *testing.T) { | |||
| 47 | + | t.Parallel() | |||
| 48 | + | for _, filename := range append(append([]string{}, licenseNames...), "LiCeNsE.MD", "missing", "directory") { | |||
| 49 | + | t.Run(filename, func(t *testing.T) { | |||
| 50 | + | t.Parallel() | |||
| 51 | + | browse := mocks.NewBrowseService(t) | |||
| 52 | + | repo := repoName(t, "proj") | |||
| 53 | + | head := strings.Repeat("a", 40) | |||
| 54 | + | entry := domain.TreeEntry{Name: filename, Path: filename, Kind: domain.EntryFile} | |||
| 55 | + | if filename == "directory" { | |||
| 56 | + | entry.Name, entry.Kind = "LICENSE", domain.EntryDirectory | |||
| 57 | + | } | |||
| 58 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{entry}}, nil).Once() | |||
| 59 | + | found := filename != "missing" && filename != "directory" | |||
| 60 | + | if found { | |||
| 61 | + | browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("SPDX-License-Identifier: MIT")), nil).Once() | |||
| 62 | + | } | |||
| 63 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse}) | |||
| 64 | + | for range 2 { | |||
| 65 | + | badge := srv.repositoryLicense(t.Context(), repo, head) | |||
| 66 | + | if !found { | |||
| 67 | + | assert.Nil(t, badge) | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | require.NotNil(t, badge) | |||
| 71 | + | assert.Equal(t, "MIT", badge.ID) | |||
| 72 | + | assert.Equal(t, "/proj/blob/"+head+"/"+filename, badge.Href) | |||
| 73 | + | } | |||
| 74 | + | }) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestRepositoryLicenseRetriesErrorsAndNewHeads(t *testing.T) { | |||
| 79 | + | t.Parallel() | |||
| 80 | + | browse := mocks.NewBrowseService(t) | |||
| 81 | + | repo := repoName(t, "proj") | |||
| 82 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, errors.New("unavailable")).Once() | |||
| 83 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Twice() | |||
| 84 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse}) | |||
| 85 | + | for _, head := range []string{strings.Repeat("a", 40), strings.Repeat("a", 40), strings.Repeat("b", 40)} { | |||
| 86 | + | assert.Nil(t, srv.repositoryLicense(context.Background(), repo, head)) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestSummaryLicense(t *testing.T) { | |||
| 91 | + | t.Parallel() | |||
| 92 | + | assert.NotContains(t, renderRail(t, SummaryPage{}), "<dt>license</dt>") | |||
| 93 | + | out := renderRail(t, SummaryPage{License: &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}}) | |||
| 94 | + | assert.Contains(t, out, `<dt>license</dt><dd><a href="/proj/blob/main/LICENSE">MIT</a></dd>`) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestHomeLicenseFromDefaultHead(t *testing.T) { | |||
| 98 | + | t.Parallel() | |||
| 99 | + | for _, binary := range []bool{false, true} { | |||
| 100 | + | name := "unknown text" | |||
| 101 | + | if binary { | |||
| 102 | + | name = "binary" | |||
| 103 | + | } | |||
| 104 | + | t.Run(name, func(t *testing.T) { | |||
| 105 | + | t.Parallel() | |||
| 106 | + | browse := mocks.NewBrowseService(t) | |||
| 107 | + | repo := pushedRepo(t, "proj") | |||
| 108 | + | commits := homeCommits(t, 1) | |||
| 109 | + | head := commits[0].ID.String() | |||
| 110 | + | revision, err := domain.ParseRevision(head) | |||
| 111 | + | require.NoError(t, err) | |||
| 112 | + | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo.Name, mock.Anything).Return(ports.HistoryPage{Commits: commits}, nil).Once() | |||
| 113 | + | browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo.Name, commits[0].ID, mock.Anything).Return(ports.RepoStats{}, nil).Once() | |||
| 114 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{{Name: "COPYING", Path: "COPYING", Kind: domain.EntryFile}}}, nil).Once() | |||
| 115 | + | browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything).Return(domain.BlobInfo{Binary: binary}, io.NopCloser(strings.NewReader("A custom license")), nil).Once() | |||
| 116 | + | srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: browse}) | |||
| 117 | + | row := srv.homeRowOf(t.Context(), repo, fixedNow, false) | |||
| 118 | + | require.NotNil(t, row.License) | |||
| 119 | + | assert.Equal(t, "Other", row.License.ID) | |||
| 120 | + | assert.Contains(t, renderRow(t, row), `href="/proj/blob/`+head+`/COPYING" title="License: Other">Other</a>`) | |||
| 121 | + | assert.Contains(t, renderRail(t, SummaryPage{License: row.License}), `<dt>license</dt><dd><a href="/proj/blob/`+head+`/COPYING">Other</a></dd>`) | |||
| 122 | + | }) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestLicenseFullTexts(t *testing.T) { | |||
| 127 | + | t.Parallel() | |||
| 128 | + | const grant = `Copyright (c) 2026 Example | |||
| 129 | + | ||||
| 130 | + | Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| 131 | + | of this software and associated documentation files (the "Software"), to deal | |||
| 132 | + | in the Software without restriction, including without limitation the rights | |||
| 133 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
| 134 | + | copies of the Software, and to permit persons to whom the Software is | |||
| 135 | + | furnished to do so.` | |||
| 136 | + | const notice = `The above copyright notice and this permission notice shall be included in | |||
| 137 | + | all copies or substantial portions of the Software.` | |||
| 138 | + | const warranty = `THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
| 139 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
| 140 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
| 141 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
| 142 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
| 143 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |||
| 144 | + | THE SOFTWARE.` | |||
| 145 | + | const bsd = `Copyright (c) 2026 Example. All rights reserved. | |||
| 146 | + | Redistribution and use in source and binary forms, with or without | |||
| 147 | + | modification, are permitted provided that the following conditions are met: | |||
| 148 | + | 1. Redistributions of source code must retain the above copyright notice, | |||
| 149 | + | this list of conditions and the following disclaimer. | |||
| 150 | + | 2. Redistributions in binary form must reproduce the above copyright notice, | |||
| 151 | + | this list of conditions and the following disclaimer in the documentation | |||
| 152 | + | and/or other materials provided with the distribution.` | |||
| 153 | + | const endorsement = `3. Neither the name of the copyright holder nor the names of its | |||
| 154 | + | contributors may be used to endorse or promote products derived from | |||
| 155 | + | this software without specific prior written permission.` | |||
| 156 | + | const disclaimer = `THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | |||
| 157 | + | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | |||
| 158 | + | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE | |||
| 159 | + | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE | |||
| 160 | + | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR | |||
| 161 | + | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF | |||
| 162 | + | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS | |||
| 163 | + | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN | |||
| 164 | + | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) | |||
| 165 | + | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE | |||
| 166 | + | POSSIBILITY OF SUCH DAMAGE.` | |||
| 167 | + | const gnu = `Copyright (C) 2007 Free Software Foundation, Inc. | |||
| 168 | + | Everyone is permitted to copy and distribute verbatim copies of this license | |||
| 169 | + | document, but changing it is not allowed. | |||
| 170 | + | ||||
| 171 | + | Preamble | |||
| 172 | + | The licenses for most software and other practical works are designed to | |||
| 173 | + | take away your freedom to share and change the works. By contrast, the GNU | |||
| 174 | + | General Public License is intended to guarantee your freedom to share and | |||
| 175 | + | change all versions of a program--to make sure it remains free software | |||
| 176 | + | for all its users.` | |||
| 177 | + | for _, tc := range []struct{ name, text, want string }{ | |||
| 178 | + | {"MIT", "MIT License\n" + grant + "\n" + notice + "\n" + warranty, "MIT"}, | |||
| 179 | + | {"MIT-0", "MIT No Attribution\n" + grant + "\n" + warranty, "MIT-0"}, | |||
| 180 | + | {"BSD-2", bsd + "\n" + disclaimer, "BSD-2-Clause"}, | |||
| 181 | + | {"BSD-3", bsd + "\n" + endorsement + "\n" + disclaimer, "BSD-3-Clause"}, | |||
| 182 | + | {"GPL", "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + strings.Repeat("\nTerms and conditions for copying, distribution and modification.", 10) + "\nUse with the GNU Affero General Public License version 3.", "GPL-3.0"}, | |||
| 183 | + | {"LGPL", "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + "\nThis version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by additional permissions.", "LGPL-3.0"}, | |||
| 184 | + | {"AGPL", "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007\n" + gnu + "\nNotwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network an opportunity to receive the Corresponding Source.", "AGPL-3.0"}, | |||
| 185 | + | } { | |||
| 186 | + | t.Run(tc.name, func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) }) | |||
| 187 | + | } | |||
| 188 | + | } | |||
M internal/adapters/web/pages.templ +5 -1
| @@ -69,7 +69,7 @@ templ Home(l Layout, p HomePage) { | |||
| 69 | 69 | templ Summary(l Layout, p SummaryPage) { | |
| 70 | 70 | @Shell(l, summaryRail(p)) { | |
| 71 | 71 | if p.Readme != nil { | |
| 72 | - | @ReadmeCard(*p.Readme) | |
| 72 | + | @ReadmeCard(*p.Readme, p.Docs...) | |
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | 75 | } | |
| @@ -112,6 +112,10 @@ templ summaryRail(p SummaryPage) { | |||
| 112 | 112 | <dt>{ f.Key }</dt> | |
| 113 | 113 | @FactValue(f) | |
| 114 | 114 | } | |
| 115 | + | if lic := p.License; lic != nil { | |
| 116 | + | <dt>license</dt> | |
| 117 | + | <dd><a href={ templ.SafeURL(lic.Href) }>{ lic.ID }</a></dd> | |
| 118 | + | } | |
| 115 | 119 | if p.Latest != nil { | |
| 116 | 120 | @latestTagFact(*p.Latest) | |
| 117 | 121 | } | |
| @@ -69,7 +69,7 @@ templ Home(l Layout, p HomePage) { | |||||
| 69 | templ Summary(l Layout, p SummaryPage) { | 69 | templ Summary(l Layout, p SummaryPage) { | ||
| 70 | @Shell(l, summaryRail(p)) { | 70 | @Shell(l, summaryRail(p)) { | ||
| 71 | if p.Readme != nil { | 71 | if p.Readme != nil { | ||
| 72 | - | @ReadmeCard(*p.Readme) | 72 | + | @ReadmeCard(*p.Readme, p.Docs...) |
| 73 | } | 73 | } | ||
| 74 | } | 74 | } | ||
| 75 | } | 75 | } | ||
| @@ -112,6 +112,10 @@ templ summaryRail(p SummaryPage) { | |||||
| 112 | <dt>{ f.Key }</dt> | 112 | <dt>{ f.Key }</dt> | ||
| 113 | @FactValue(f) | 113 | @FactValue(f) | ||
| 114 | } | 114 | } | ||
| 115 | + | if lic := p.License; lic != nil { | |||
| 116 | + | <dt>license</dt> | |||
| 117 | + | <dd><a href={ templ.SafeURL(lic.Href) }>{ lic.ID }</a></dd> | |||
| 118 | + | } | |||
| 115 | if p.Latest != nil { | 119 | if p.Latest != nil { | ||
| 116 | @latestTagFact(*p.Latest) | 120 | @latestTagFact(*p.Latest) | ||
| 117 | } | 121 | } | ||
M internal/adapters/web/pages_templ.go +331 -299 generated file: collapsed
M internal/adapters/web/pathcache.go +16 -6
| @@ -70,6 +70,14 @@ func (c *pathCache) getLocked(id string) ([]byte, bool) { | |||
| 70 | 70 | return el.Value.(*pathEntry).body, true | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | + | // entryOverhead is what an entry costs besides its id and body: the list | |
| 74 | + | // element, the entry and the map slot. Counted so that tiny bodies (a cached | |
| 75 | + | // "no license") cannot pile up far past the budget. | |
| 76 | + | const entryOverhead = 128 | |
| 77 | + | ||
| 78 | + | // cost is what an entry counts against the budget. | |
| 79 | + | func (e *pathEntry) cost() int { return len(e.id) + len(e.body) + entryOverhead } | |
| 80 | + | ||
| 73 | 81 | // putLocked stores a list, evicting the least recently used ones to stay in | |
| 74 | 82 | // budget. A list over a quarter of the budget is not stored. | |
| 75 | 83 | func (c *pathCache) putLocked(id string, body []byte) { | |
| @@ -77,20 +85,22 @@ func (c *pathCache) putLocked(id string, body []byte) { | |||
| 77 | 85 | return | |
| 78 | 86 | } | |
| 79 | 87 | if el, ok := c.byID[id]; ok { | |
| 80 | - | c.size -= len(el.Value.(*pathEntry).body) | |
| 81 | - | el.Value.(*pathEntry).body = body | |
| 82 | - | c.size += len(body) | |
| 88 | + | e := el.Value.(*pathEntry) | |
| 89 | + | c.size -= e.cost() | |
| 90 | + | e.body = body | |
| 91 | + | c.size += e.cost() | |
| 83 | 92 | c.lru.MoveToFront(el) | |
| 84 | 93 | } else { | |
| 85 | - | c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body}) | |
| 86 | - | c.size += len(body) | |
| 94 | + | e := &pathEntry{id: id, body: body} | |
| 95 | + | c.byID[id] = c.lru.PushFront(e) | |
| 96 | + | c.size += e.cost() | |
| 87 | 97 | } | |
| 88 | 98 | for c.size > c.budget { | |
| 89 | 99 | el := c.lru.Back() | |
| 90 | 100 | e := el.Value.(*pathEntry) | |
| 91 | 101 | c.lru.Remove(el) | |
| 92 | 102 | delete(c.byID, e.id) | |
| 93 | - | c.size -= len(e.body) | |
| 103 | + | c.size -= e.cost() | |
| 94 | 104 | } | |
| 95 | 105 | } | |
| 96 | 106 | ||
| @@ -70,6 +70,14 @@ func (c *pathCache) getLocked(id string) ([]byte, bool) { | |||||
| 70 | return el.Value.(*pathEntry).body, true | 70 | return el.Value.(*pathEntry).body, true | ||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | + | // entryOverhead is what an entry costs besides its id and body: the list | |||
| 74 | + | // element, the entry and the map slot. Counted so that tiny bodies (a cached | |||
| 75 | + | // "no license") cannot pile up far past the budget. | |||
| 76 | + | const entryOverhead = 128 | |||
| 77 | + | ||||
| 78 | + | // cost is what an entry counts against the budget. | |||
| 79 | + | func (e *pathEntry) cost() int { return len(e.id) + len(e.body) + entryOverhead } | |||
| 80 | + | ||||
| 73 | // putLocked stores a list, evicting the least recently used ones to stay in | 81 | // putLocked stores a list, evicting the least recently used ones to stay in | ||
| 74 | // budget. A list over a quarter of the budget is not stored. | 82 | // budget. A list over a quarter of the budget is not stored. | ||
| 75 | func (c *pathCache) putLocked(id string, body []byte) { | 83 | func (c *pathCache) putLocked(id string, body []byte) { | ||
| @@ -77,20 +85,22 @@ func (c *pathCache) putLocked(id string, body []byte) { | |||||
| 77 | return | 85 | return | ||
| 78 | } | 86 | } | ||
| 79 | if el, ok := c.byID[id]; ok { | 87 | if el, ok := c.byID[id]; ok { | ||
| 80 | - | c.size -= len(el.Value.(*pathEntry).body) | 88 | + | e := el.Value.(*pathEntry) |
| 81 | - | el.Value.(*pathEntry).body = body | 89 | + | c.size -= e.cost() |
| 82 | - | c.size += len(body) | 90 | + | e.body = body |
| 91 | + | c.size += e.cost() | |||
| 83 | c.lru.MoveToFront(el) | 92 | c.lru.MoveToFront(el) | ||
| 84 | } else { | 93 | } else { | ||
| 85 | - | c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body}) | 94 | + | e := &pathEntry{id: id, body: body} |
| 86 | - | c.size += len(body) | 95 | + | c.byID[id] = c.lru.PushFront(e) |
| 96 | + | c.size += e.cost() | |||
| 87 | } | 97 | } | ||
| 88 | for c.size > c.budget { | 98 | for c.size > c.budget { | ||
| 89 | el := c.lru.Back() | 99 | el := c.lru.Back() | ||
| 90 | e := el.Value.(*pathEntry) | 100 | e := el.Value.(*pathEntry) | ||
| 91 | c.lru.Remove(el) | 101 | c.lru.Remove(el) | ||
| 92 | delete(c.byID, e.id) | 102 | delete(c.byID, e.id) | ||
| 93 | - | c.size -= len(e.body) | 103 | + | c.size -= e.cost() |
| 94 | } | 104 | } | ||
| 95 | } | 105 | } | ||
| 96 | 106 | ||||
M internal/adapters/web/pathcache_test.go +34 -17
| @@ -30,55 +30,72 @@ func (c *pathCache) has(id string) bool { | |||
| 30 | 30 | return ok | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | // entry is the budget an entry of a one-letter id and an n-byte body takes. | |
| 34 | + | func entry(n int) int { return 1 + n + entryOverhead } | |
| 35 | + | ||
| 33 | 36 | func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | |
| 34 | 37 | t.Parallel() | |
| 35 | - | c := newPathCache(100, 1) | |
| 38 | + | budget := 4 * entry(100) | |
| 39 | + | c := newPathCache(budget, 1) | |
| 36 | 40 | blob := func(n int) []byte { return make([]byte, n) } | |
| 37 | - | c.store("a", blob(25)) | |
| 38 | - | c.store("b", blob(25)) | |
| 39 | - | c.store("e", blob(25)) | |
| 41 | + | c.store("a", blob(100)) | |
| 42 | + | c.store("b", blob(100)) | |
| 43 | + | c.store("e", blob(100)) | |
| 40 | 44 | require.True(t, c.has("a")) // a is now the most recent | |
| 41 | - | c.store("c", blob(25)) // 100 fit | |
| 42 | - | c.store("d", blob(10)) // 110 > 100: b, the least recent, goes | |
| 45 | + | c.store("c", blob(100)) // four fit | |
| 46 | + | c.store("d", blob(10)) // one more: b, the least recent, goes | |
| 43 | 47 | assert.False(t, c.has("b"), "b was evicted") | |
| 44 | 48 | assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e")) | |
| 45 | - | assert.Equal(t, 85, c.size) | |
| 49 | + | assert.Equal(t, 3*entry(100)+entry(10), c.size) | |
| 46 | 50 | ||
| 47 | 51 | // Replacing an entry accounts for the old size. | |
| 48 | 52 | c.store("a", blob(10)) | |
| 49 | - | assert.Equal(t, 70, c.size) | |
| 53 | + | assert.Equal(t, 2*entry(100)+2*entry(10), c.size) | |
| 50 | 54 | ||
| 51 | 55 | // A big one pushes out as many as needed. | |
| 52 | - | c.store("f", blob(25)) | |
| 53 | - | c.store("g", blob(25)) | |
| 54 | - | assert.LessOrEqual(t, c.size, 100) | |
| 56 | + | c.store("f", blob(100)) | |
| 57 | + | c.store("g", blob(100)) | |
| 58 | + | assert.LessOrEqual(t, c.size, budget) | |
| 55 | 59 | assert.True(t, c.has("g")) | |
| 56 | 60 | } | |
| 57 | 61 | ||
| 62 | + | // Tiny bodies (a cached "no license") count their id and overhead too, so | |
| 63 | + | // their number stays bounded by the budget. | |
| 64 | + | func TestPathCacheBoundsTinyEntries(t *testing.T) { | |
| 65 | + | t.Parallel() | |
| 66 | + | c := newPathCache(1<<12, 1) | |
| 67 | + | for i := range 10_000 { | |
| 68 | + | c.store(fmt.Sprintf("repo\x00%040d", i), []byte("null")) | |
| 69 | + | } | |
| 70 | + | assert.LessOrEqual(t, c.size, 1<<12) | |
| 71 | + | assert.Less(t, len(c.byID), (1<<12)/entryOverhead+1) | |
| 72 | + | assert.Equal(t, len(c.byID), c.lru.Len()) | |
| 73 | + | } | |
| 74 | + | ||
| 58 | 75 | // A list over a quarter of the budget is not kept, since it would push out the | |
| 59 | 76 | // others; one at the limit is. | |
| 60 | 77 | func TestPathCacheSkipsOversizedEntries(t *testing.T) { | |
| 61 | 78 | t.Parallel() | |
| 62 | - | c := newPathCache(100, 1) | |
| 79 | + | c := newPathCache(1000, 1) | |
| 63 | 80 | var builds atomic.Int32 | |
| 64 | 81 | big := func(n int) func() ([]byte, error) { | |
| 65 | 82 | return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil } | |
| 66 | 83 | } | |
| 67 | 84 | for range 2 { | |
| 68 | - | b, err := c.load(context.Background(), "r", "big", big(26)) | |
| 85 | + | b, err := c.load(context.Background(), "r", "big", big(251)) | |
| 69 | 86 | require.NoError(t, err) | |
| 70 | - | assert.Len(t, b, 26, "the caller still gets the list") | |
| 87 | + | assert.Len(t, b, 251, "the caller still gets the list") | |
| 71 | 88 | } | |
| 72 | 89 | assert.EqualValues(t, 2, builds.Load(), "not cached, so built again") | |
| 73 | 90 | assert.False(t, c.has("big")) | |
| 74 | 91 | assert.Zero(t, c.size) | |
| 75 | 92 | ||
| 76 | 93 | for range 2 { | |
| 77 | - | _, err := c.load(context.Background(), "r", "fits", big(25)) | |
| 94 | + | _, err := c.load(context.Background(), "r", "fits", big(250)) | |
| 78 | 95 | require.NoError(t, err) | |
| 79 | 96 | } | |
| 80 | 97 | assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept") | |
| 81 | - | assert.Equal(t, 25, c.size) | |
| 98 | + | assert.Equal(t, len("fits")+250+entryOverhead, c.size) | |
| 82 | 99 | } | |
| 83 | 100 | ||
| 84 | 101 | func TestPathCacheConcurrent(t *testing.T) { | |
| @@ -99,7 +116,7 @@ func TestPathCacheConcurrent(t *testing.T) { | |||
| 99 | 116 | assert.LessOrEqual(t, c.size, 1000) | |
| 100 | 117 | sum := 0 | |
| 101 | 118 | for _, el := range c.byID { | |
| 102 | - | sum += len(el.Value.(*pathEntry).body) | |
| 119 | + | sum += el.Value.(*pathEntry).cost() | |
| 103 | 120 | } | |
| 104 | 121 | assert.Equal(t, sum, c.size, "the size is the sum of the entries") | |
| 105 | 122 | assert.Equal(t, len(c.byID), c.lru.Len()) | |
| @@ -30,55 +30,72 @@ func (c *pathCache) has(id string) bool { | |||||
| 30 | return ok | 30 | return ok | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | // entry is the budget an entry of a one-letter id and an n-byte body takes. | |||
| 34 | + | func entry(n int) int { return 1 + n + entryOverhead } | |||
| 35 | + | ||||
| 33 | func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | 36 | func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | ||
| 34 | t.Parallel() | 37 | t.Parallel() | ||
| 35 | - | c := newPathCache(100, 1) | 38 | + | budget := 4 * entry(100) |
| 39 | + | c := newPathCache(budget, 1) | |||
| 36 | blob := func(n int) []byte { return make([]byte, n) } | 40 | blob := func(n int) []byte { return make([]byte, n) } | ||
| 37 | - | c.store("a", blob(25)) | 41 | + | c.store("a", blob(100)) |
| 38 | - | c.store("b", blob(25)) | 42 | + | c.store("b", blob(100)) |
| 39 | - | c.store("e", blob(25)) | 43 | + | c.store("e", blob(100)) |
| 40 | require.True(t, c.has("a")) // a is now the most recent | 44 | require.True(t, c.has("a")) // a is now the most recent | ||
| 41 | - | c.store("c", blob(25)) // 100 fit | 45 | + | c.store("c", blob(100)) // four fit |
| 42 | - | c.store("d", blob(10)) // 110 > 100: b, the least recent, goes | 46 | + | c.store("d", blob(10)) // one more: b, the least recent, goes |
| 43 | assert.False(t, c.has("b"), "b was evicted") | 47 | assert.False(t, c.has("b"), "b was evicted") | ||
| 44 | assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e")) | 48 | assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e")) | ||
| 45 | - | assert.Equal(t, 85, c.size) | 49 | + | assert.Equal(t, 3*entry(100)+entry(10), c.size) |
| 46 | 50 | ||||
| 47 | // Replacing an entry accounts for the old size. | 51 | // Replacing an entry accounts for the old size. | ||
| 48 | c.store("a", blob(10)) | 52 | c.store("a", blob(10)) | ||
| 49 | - | assert.Equal(t, 70, c.size) | 53 | + | assert.Equal(t, 2*entry(100)+2*entry(10), c.size) |
| 50 | 54 | ||||
| 51 | // A big one pushes out as many as needed. | 55 | // A big one pushes out as many as needed. | ||
| 52 | - | c.store("f", blob(25)) | 56 | + | c.store("f", blob(100)) |
| 53 | - | c.store("g", blob(25)) | 57 | + | c.store("g", blob(100)) |
| 54 | - | assert.LessOrEqual(t, c.size, 100) | 58 | + | assert.LessOrEqual(t, c.size, budget) |
| 55 | assert.True(t, c.has("g")) | 59 | assert.True(t, c.has("g")) | ||
| 56 | } | 60 | } | ||
| 57 | 61 | ||||
| 62 | + | // Tiny bodies (a cached "no license") count their id and overhead too, so | |||
| 63 | + | // their number stays bounded by the budget. | |||
| 64 | + | func TestPathCacheBoundsTinyEntries(t *testing.T) { | |||
| 65 | + | t.Parallel() | |||
| 66 | + | c := newPathCache(1<<12, 1) | |||
| 67 | + | for i := range 10_000 { | |||
| 68 | + | c.store(fmt.Sprintf("repo\x00%040d", i), []byte("null")) | |||
| 69 | + | } | |||
| 70 | + | assert.LessOrEqual(t, c.size, 1<<12) | |||
| 71 | + | assert.Less(t, len(c.byID), (1<<12)/entryOverhead+1) | |||
| 72 | + | assert.Equal(t, len(c.byID), c.lru.Len()) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 58 | // A list over a quarter of the budget is not kept, since it would push out the | 75 | // A list over a quarter of the budget is not kept, since it would push out the | ||
| 59 | // others; one at the limit is. | 76 | // others; one at the limit is. | ||
| 60 | func TestPathCacheSkipsOversizedEntries(t *testing.T) { | 77 | func TestPathCacheSkipsOversizedEntries(t *testing.T) { | ||
| 61 | t.Parallel() | 78 | t.Parallel() | ||
| 62 | - | c := newPathCache(100, 1) | 79 | + | c := newPathCache(1000, 1) |
| 63 | var builds atomic.Int32 | 80 | var builds atomic.Int32 | ||
| 64 | big := func(n int) func() ([]byte, error) { | 81 | big := func(n int) func() ([]byte, error) { | ||
| 65 | return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil } | 82 | return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil } | ||
| 66 | } | 83 | } | ||
| 67 | for range 2 { | 84 | for range 2 { | ||
| 68 | - | b, err := c.load(context.Background(), "r", "big", big(26)) | 85 | + | b, err := c.load(context.Background(), "r", "big", big(251)) |
| 69 | require.NoError(t, err) | 86 | require.NoError(t, err) | ||
| 70 | - | assert.Len(t, b, 26, "the caller still gets the list") | 87 | + | assert.Len(t, b, 251, "the caller still gets the list") |
| 71 | } | 88 | } | ||
| 72 | assert.EqualValues(t, 2, builds.Load(), "not cached, so built again") | 89 | assert.EqualValues(t, 2, builds.Load(), "not cached, so built again") | ||
| 73 | assert.False(t, c.has("big")) | 90 | assert.False(t, c.has("big")) | ||
| 74 | assert.Zero(t, c.size) | 91 | assert.Zero(t, c.size) | ||
| 75 | 92 | ||||
| 76 | for range 2 { | 93 | for range 2 { | ||
| 77 | - | _, err := c.load(context.Background(), "r", "fits", big(25)) | 94 | + | _, err := c.load(context.Background(), "r", "fits", big(250)) |
| 78 | require.NoError(t, err) | 95 | require.NoError(t, err) | ||
| 79 | } | 96 | } | ||
| 80 | assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept") | 97 | assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept") | ||
| 81 | - | assert.Equal(t, 25, c.size) | 98 | + | assert.Equal(t, len("fits")+250+entryOverhead, c.size) |
| 82 | } | 99 | } | ||
| 83 | 100 | ||||
| 84 | func TestPathCacheConcurrent(t *testing.T) { | 101 | func TestPathCacheConcurrent(t *testing.T) { | ||
| @@ -99,7 +116,7 @@ func TestPathCacheConcurrent(t *testing.T) { | |||||
| 99 | assert.LessOrEqual(t, c.size, 1000) | 116 | assert.LessOrEqual(t, c.size, 1000) | ||
| 100 | sum := 0 | 117 | sum := 0 | ||
| 101 | for _, el := range c.byID { | 118 | for _, el := range c.byID { | ||
| 102 | - | sum += len(el.Value.(*pathEntry).body) | 119 | + | sum += el.Value.(*pathEntry).cost() |
| 103 | } | 120 | } | ||
| 104 | assert.Equal(t, sum, c.size, "the size is the sum of the entries") | 121 | assert.Equal(t, sum, c.size, "the size is the sum of the entries") | ||
| 105 | assert.Equal(t, len(c.byID), c.lru.Len()) | 122 | assert.Equal(t, len(c.byID), c.lru.Len()) | ||
M internal/adapters/web/static/css/00-tokens.css +1 -0
| @@ -78,6 +78,7 @@ | |||
| 78 | 78 | --fs-code: 13px; | |
| 79 | 79 | --fs-s: 13px; | |
| 80 | 80 | --fs-xs: 12px; | |
| 81 | + | --fs-2xs: 11px; | |
| 81 | 82 | --lh: 24px; | |
| 82 | 83 | --lh-code: 20px; | |
| 83 | 84 | ||
| @@ -78,6 +78,7 @@ | |||||
| 78 | --fs-code: 13px; | 78 | --fs-code: 13px; | ||
| 79 | --fs-s: 13px; | 79 | --fs-s: 13px; | ||
| 80 | --fs-xs: 12px; | 80 | --fs-xs: 12px; | ||
| 81 | + | --fs-2xs: 11px; | |||
| 81 | --lh: 24px; | 82 | --lh: 24px; | ||
| 82 | --lh-code: 20px; | 83 | --lh-code: 20px; | ||
| 83 | 84 | ||||
M internal/adapters/web/static/css/20-shell.css +6 -2
| @@ -29,15 +29,19 @@ | |||
| 29 | 29 | .i-cards { --i: url(icons/cards.svg); } | |
| 30 | 30 | .i-split { --i: url(icons/split.svg); } | |
| 31 | 31 | .i-compact { --i: url(icons/compact.svg); } | |
| 32 | + | .i-refresh-cw { --i: url(icons/refresh-cw.svg); } | |
| 32 | 33 | .i-mailbox { --i: url(icons/mailbox.svg); } | |
| 33 | 34 | .i-sun-moon { --i: url(icons/sun-moon.svg); } | |
| 34 | 35 | .i-sun { --i: url(icons/sun.svg); } | |
| 35 | 36 | .i-moon { --i: url(icons/moon.svg); } | |
| 36 | 37 | ||
| 37 | 38 | .pill { | |
| 38 | - | display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1); | |
| 39 | - | font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px; | |
| 39 | + | display: inline-flex; align-items: center; height: 18px; padding: 0 var(--s2); margin-left: var(--s1); | |
| 40 | + | font: 600 var(--fs-2xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px; | |
| 40 | 41 | background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap; | |
| 42 | + | /* a link pill: no underline; the border lightens on hover and focus */ | |
| 43 | + | &:is(a) { text-decoration: none; cursor: pointer; transition: border-color .12s; } | |
| 44 | + | &:is(a):hover, &:is(a):focus-visible { text-decoration: none; border-color: var(--ink-dim); } | |
| 41 | 45 | &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); } | |
| 42 | 46 | &.new { color: var(--teal); border-color: currentColor; } | |
| 43 | 47 | /* the signature of a commit: the protocol, then the verdict, always as text | |
| @@ -29,15 +29,19 @@ | |||||
| 29 | .i-cards { --i: url(icons/cards.svg); } | 29 | .i-cards { --i: url(icons/cards.svg); } | ||
| 30 | .i-split { --i: url(icons/split.svg); } | 30 | .i-split { --i: url(icons/split.svg); } | ||
| 31 | .i-compact { --i: url(icons/compact.svg); } | 31 | .i-compact { --i: url(icons/compact.svg); } | ||
| 32 | + | .i-refresh-cw { --i: url(icons/refresh-cw.svg); } | |||
| 32 | .i-mailbox { --i: url(icons/mailbox.svg); } | 33 | .i-mailbox { --i: url(icons/mailbox.svg); } | ||
| 33 | .i-sun-moon { --i: url(icons/sun-moon.svg); } | 34 | .i-sun-moon { --i: url(icons/sun-moon.svg); } | ||
| 34 | .i-sun { --i: url(icons/sun.svg); } | 35 | .i-sun { --i: url(icons/sun.svg); } | ||
| 35 | .i-moon { --i: url(icons/moon.svg); } | 36 | .i-moon { --i: url(icons/moon.svg); } | ||
| 36 | 37 | ||||
| 37 | .pill { | 38 | .pill { | ||
| 38 | - | display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1); | 39 | + | display: inline-flex; align-items: center; height: 18px; padding: 0 var(--s2); margin-left: var(--s1); |
| 39 | - | font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px; | 40 | + | font: 600 var(--fs-2xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px; |
| 40 | background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap; | 41 | background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap; | ||
| 42 | + | /* a link pill: no underline; the border lightens on hover and focus */ | |||
| 43 | + | &:is(a) { text-decoration: none; cursor: pointer; transition: border-color .12s; } | |||
| 44 | + | &:is(a):hover, &:is(a):focus-visible { text-decoration: none; border-color: var(--ink-dim); } | |||
| 41 | &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); } | 45 | &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); } | ||
| 42 | &.new { color: var(--teal); border-color: currentColor; } | 46 | &.new { color: var(--teal); border-color: currentColor; } | ||
| 43 | /* the signature of a commit: the protocol, then the verdict, always as text | 47 | /* the signature of a commit: the protocol, then the verdict, always as text | ||
M internal/adapters/web/static/css/30-list.css +10 -10
| @@ -69,8 +69,12 @@ | |||
| 69 | 69 | & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; } | |
| 70 | 70 | & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; } | |
| 71 | 71 | & .repo-name a { color: var(--ink-hi); text-decoration: none; } | |
| 72 | - | & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; } | |
| 72 | + | & .repo:hover .repo-name a:not(.pill), & .repo[data-cursor] .repo-name a:not(.pill) { text-decoration: underline; } | |
| 73 | 73 | & .repo-name .pill { margin-left: 0; } | |
| 74 | + | & .repo-name a.pill { display: block; line-height: 18px; min-width: 0; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: normal; } | |
| 75 | + | & .repo-name .repo-release { color: var(--accent); } | |
| 76 | + | & .repo-name .repo-license { color: var(--ink-dim); } | |
| 77 | + | & .repo-activity { display: inline-flex; align-items: center; flex: none; color: var(--accent); } | |
| 74 | 78 | /* Every card has the same height: two lines of description, one of topics | |
| 75 | 79 | (reserved when there are none), five of commits. */ | |
| 76 | 80 | & .repo-desc { | |
| @@ -123,7 +127,7 @@ | |||
| 123 | 127 | & .repo-stats dd a { color: inherit; text-decoration: none; } | |
| 124 | 128 | & .repo-stats dd a:hover { text-decoration: underline; } | |
| 125 | 129 | & .repo-ci { | |
| 126 | - | flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px; | |
| 130 | + | flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; | |
| 127 | 131 | font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap; | |
| 128 | 132 | } | |
| 129 | 133 | & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; } | |
| @@ -170,7 +174,7 @@ | |||
| 170 | 174 | } | |
| 171 | 175 | & .repo-main, & .repo-head { display: contents; } | |
| 172 | 176 | & .repo-name { grid-area: name; align-self: center; font-size: 16px; } | |
| 173 | - | & .repo-ci { grid-area: ci; align-self: center; padding-top: 0; } | |
| 177 | + | & .repo-ci { grid-area: ci; align-self: center; } | |
| 174 | 178 | & .repo-desc { grid-area: desc; margin-top: var(--s2); } | |
| 175 | 179 | & .repo-topics { grid-area: topics; margin-top: var(--s2); } | |
| 176 | 180 | & .repo-topics:empty { display: none; } | |
| @@ -212,7 +216,8 @@ | |||
| 212 | 216 | /* min-width 0: a long name is cut, it does not widen the column past its cap */ | |
| 213 | 217 | & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; } | |
| 214 | 218 | & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 215 | - | & .repo-name .pill { flex: none; } | |
| 219 | + | & .repo-name .pill { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } | |
| 220 | + | & .repo-name a.pill { max-width: 12ch; } | |
| 216 | 221 | & .repo-desc { | |
| 217 | 222 | grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 218 | 223 | font-size: var(--fs-s); color: var(--ink-mute); | |
| @@ -227,13 +232,8 @@ | |||
| 227 | 232 | position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); | |
| 228 | 233 | } | |
| 229 | 234 | & .repo-stats .rs-tag dd { max-width: 10ch; } | |
| 230 | - | & .repo-stats .rs-tag dd::before { | |
| 231 | - | content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px; | |
| 232 | - | background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat; | |
| 233 | - | } | |
| 234 | 235 | /* the CI state is its mark; the word is in the tooltip and for screen readers */ | |
| 235 | - | & .repo-ci { grid-area: ci; justify-self: center; padding-top: 0; } | |
| 236 | - | & .repo-ci .ci-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } | |
| 236 | + | & .repo-ci { grid-area: ci; justify-self: center; } | |
| 237 | 237 | /* the pulse: the same curve, small, with a bigger dot to stay seen */ | |
| 238 | 238 | & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; } | |
| 239 | 239 | & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; } | |
| @@ -69,8 +69,12 @@ | |||||
| 69 | & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; } | 69 | & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; } | ||
| 70 | & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; } | 70 | & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; } | ||
| 71 | & .repo-name a { color: var(--ink-hi); text-decoration: none; } | 71 | & .repo-name a { color: var(--ink-hi); text-decoration: none; } | ||
| 72 | - | & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; } | 72 | + | & .repo:hover .repo-name a:not(.pill), & .repo[data-cursor] .repo-name a:not(.pill) { text-decoration: underline; } |
| 73 | & .repo-name .pill { margin-left: 0; } | 73 | & .repo-name .pill { margin-left: 0; } | ||
| 74 | + | & .repo-name a.pill { display: block; line-height: 18px; min-width: 0; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: normal; } | |||
| 75 | + | & .repo-name .repo-release { color: var(--accent); } | |||
| 76 | + | & .repo-name .repo-license { color: var(--ink-dim); } | |||
| 77 | + | & .repo-activity { display: inline-flex; align-items: center; flex: none; color: var(--accent); } | |||
| 74 | /* Every card has the same height: two lines of description, one of topics | 78 | /* Every card has the same height: two lines of description, one of topics | ||
| 75 | (reserved when there are none), five of commits. */ | 79 | (reserved when there are none), five of commits. */ | ||
| 76 | & .repo-desc { | 80 | & .repo-desc { | ||
| @@ -123,7 +127,7 @@ | |||||
| 123 | & .repo-stats dd a { color: inherit; text-decoration: none; } | 127 | & .repo-stats dd a { color: inherit; text-decoration: none; } | ||
| 124 | & .repo-stats dd a:hover { text-decoration: underline; } | 128 | & .repo-stats dd a:hover { text-decoration: underline; } | ||
| 125 | & .repo-ci { | 129 | & .repo-ci { | ||
| 126 | - | flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px; | 130 | + | flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; |
| 127 | font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap; | 131 | font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap; | ||
| 128 | } | 132 | } | ||
| 129 | & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; } | 133 | & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; } | ||
| @@ -170,7 +174,7 @@ | |||||
| 170 | } | 174 | } | ||
| 171 | & .repo-main, & .repo-head { display: contents; } | 175 | & .repo-main, & .repo-head { display: contents; } | ||
| 172 | & .repo-name { grid-area: name; align-self: center; font-size: 16px; } | 176 | & .repo-name { grid-area: name; align-self: center; font-size: 16px; } | ||
| 173 | - | & .repo-ci { grid-area: ci; align-self: center; padding-top: 0; } | 177 | + | & .repo-ci { grid-area: ci; align-self: center; } |
| 174 | & .repo-desc { grid-area: desc; margin-top: var(--s2); } | 178 | & .repo-desc { grid-area: desc; margin-top: var(--s2); } | ||
| 175 | & .repo-topics { grid-area: topics; margin-top: var(--s2); } | 179 | & .repo-topics { grid-area: topics; margin-top: var(--s2); } | ||
| 176 | & .repo-topics:empty { display: none; } | 180 | & .repo-topics:empty { display: none; } | ||
| @@ -212,7 +216,8 @@ | |||||
| 212 | /* min-width 0: a long name is cut, it does not widen the column past its cap */ | 216 | /* min-width 0: a long name is cut, it does not widen the column past its cap */ | ||
| 213 | & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; } | 217 | & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; } | ||
| 214 | & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 218 | & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 215 | - | & .repo-name .pill { flex: none; } | 219 | + | & .repo-name .pill { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } |
| 220 | + | & .repo-name a.pill { max-width: 12ch; } | |||
| 216 | & .repo-desc { | 221 | & .repo-desc { | ||
| 217 | grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | 222 | grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | ||
| 218 | font-size: var(--fs-s); color: var(--ink-mute); | 223 | font-size: var(--fs-s); color: var(--ink-mute); | ||
| @@ -227,13 +232,8 @@ | |||||
| 227 | position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); | 232 | position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); | ||
| 228 | } | 233 | } | ||
| 229 | & .repo-stats .rs-tag dd { max-width: 10ch; } | 234 | & .repo-stats .rs-tag dd { max-width: 10ch; } | ||
| 230 | - | & .repo-stats .rs-tag dd::before { | |||
| 231 | - | content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px; | |||
| 232 | - | background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat; | |||
| 233 | - | } | |||
| 234 | /* the CI state is its mark; the word is in the tooltip and for screen readers */ | 235 | /* the CI state is its mark; the word is in the tooltip and for screen readers */ | ||
| 235 | - | & .repo-ci { grid-area: ci; justify-self: center; padding-top: 0; } | 236 | + | & .repo-ci { grid-area: ci; justify-self: center; } |
| 236 | - | & .repo-ci .ci-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } | |||
| 237 | /* the pulse: the same curve, small, with a bigger dot to stay seen */ | 237 | /* the pulse: the same curve, small, with a bigger dot to stay seen */ | ||
| 238 | & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; } | 238 | & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; } | ||
| 239 | & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; } | 239 | & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; } | ||
M internal/adapters/web/static/css/40-summary.css +5 -0
| @@ -13,6 +13,11 @@ | |||
| 13 | 13 | .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; } | |
| 14 | 14 | ||
| 15 | 15 | .readme .prose { padding: var(--s5) var(--s6) var(--s6); } | |
| 16 | + | .doc-tabs { | |
| 17 | + | display: flex; flex-wrap: wrap; gap: var(--s4); | |
| 18 | + | & a { color: var(--ink-dim); } | |
| 19 | + | & a[aria-current] { color: var(--teal); text-decoration: underline; text-underline-offset: 5px; } | |
| 20 | + | } | |
| 16 | 21 | ||
| 17 | 22 | /* rendered Markdown */ | |
| 18 | 23 | .prose { | |
| @@ -13,6 +13,11 @@ | |||||
| 13 | .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; } | 13 | .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; } | ||
| 14 | 14 | ||||
| 15 | .readme .prose { padding: var(--s5) var(--s6) var(--s6); } | 15 | .readme .prose { padding: var(--s5) var(--s6) var(--s6); } | ||
| 16 | + | .doc-tabs { | |||
| 17 | + | display: flex; flex-wrap: wrap; gap: var(--s4); | |||
| 18 | + | & a { color: var(--ink-dim); } | |||
| 19 | + | & a[aria-current] { color: var(--teal); text-decoration: underline; text-underline-offset: 5px; } | |||
| 20 | + | } | |||
| 16 | 21 | ||||
| 17 | /* rendered Markdown */ | 22 | /* rendered Markdown */ | ||
| 18 | .prose { | 23 | .prose { | ||
A internal/adapters/web/static/icons/refresh-cw.svg +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | <!-- SPDX-License-Identifier: ISC --> | |
| 2 | + | <!-- Lucide 1.8: refresh-cw; https://lucide.dev/license --> | |
| 3 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg> | |
| @@ -0,0 +1,3 @@ | |||||
| 1 | + | <!-- SPDX-License-Identifier: ISC --> | |||
| 2 | + | <!-- Lucide 1.8: refresh-cw; https://lucide.dev/license --> | |||
| 3 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg> | |||
M internal/adapters/web/static/js/ermite.js +10 -1
| @@ -37,7 +37,8 @@ | |||
| 37 | 37 | flashTimer = setTimeout(() => (body().dataset.flash = ''), ms); | |
| 38 | 38 | }; | |
| 39 | 39 | ||
| 40 | - | const visibleRows = () => $$('[data-rows] > :not([hidden])'); | |
| 40 | + | // Rail tables are context, not the main row cursor. | |
| 41 | + | const visibleRows = () => $$('[data-rows] > :not([hidden])').filter((r) => !r.closest('.rail')); | |
| 41 | 42 | // setCursor puts data-cursor on el and takes it from the others. | |
| 42 | 43 | const setCursor = (el, others) => { | |
| 43 | 44 | for (const o of others) if (o !== el) o.removeAttribute('data-cursor'); | |
| @@ -1007,6 +1008,14 @@ window.ermiteLive = (() => { | |||
| 1007 | 1008 | const cursor = document.querySelector('[data-rows] > [data-cursor]'); | |
| 1008 | 1009 | const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href'); | |
| 1009 | 1010 | for (const el of [...t.content.children]) { | |
| 1011 | + | // Each fresh fragment owns its timer; an older swap cannot expire it. | |
| 1012 | + | // No animation, including for visitors who prefer reduced motion. | |
| 1013 | + | if (el.id.startsWith('repo-') && el.hasAttribute('data-activity')) { | |
| 1014 | + | setTimeout(() => { | |
| 1015 | + | el.querySelector('.repo-activity')?.remove(); | |
| 1016 | + | el.removeAttribute('data-activity'); | |
| 1017 | + | }, 60000); | |
| 1018 | + | } | |
| 1010 | 1019 | const old = el.id && document.getElementById(el.id); | |
| 1011 | 1020 | const had = old ? where(old) : null; // the focus is inside what is replaced | |
| 1012 | 1021 | if (el.hasAttribute('data-gone')) { | |
| @@ -37,7 +37,8 @@ | |||||
| 37 | flashTimer = setTimeout(() => (body().dataset.flash = ''), ms); | 37 | flashTimer = setTimeout(() => (body().dataset.flash = ''), ms); | ||
| 38 | }; | 38 | }; | ||
| 39 | 39 | ||||
| 40 | - | const visibleRows = () => $$('[data-rows] > :not([hidden])'); | 40 | + | // Rail tables are context, not the main row cursor. |
| 41 | + | const visibleRows = () => $$('[data-rows] > :not([hidden])').filter((r) => !r.closest('.rail')); | |||
| 41 | // setCursor puts data-cursor on el and takes it from the others. | 42 | // setCursor puts data-cursor on el and takes it from the others. | ||
| 42 | const setCursor = (el, others) => { | 43 | const setCursor = (el, others) => { | ||
| 43 | for (const o of others) if (o !== el) o.removeAttribute('data-cursor'); | 44 | for (const o of others) if (o !== el) o.removeAttribute('data-cursor'); | ||
| @@ -1007,6 +1008,14 @@ window.ermiteLive = (() => { | |||||
| 1007 | const cursor = document.querySelector('[data-rows] > [data-cursor]'); | 1008 | const cursor = document.querySelector('[data-rows] > [data-cursor]'); | ||
| 1008 | const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href'); | 1009 | const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href'); | ||
| 1009 | for (const el of [...t.content.children]) { | 1010 | for (const el of [...t.content.children]) { | ||
| 1011 | + | // Each fresh fragment owns its timer; an older swap cannot expire it. | |||
| 1012 | + | // No animation, including for visitors who prefer reduced motion. | |||
| 1013 | + | if (el.id.startsWith('repo-') && el.hasAttribute('data-activity')) { | |||
| 1014 | + | setTimeout(() => { | |||
| 1015 | + | el.querySelector('.repo-activity')?.remove(); | |||
| 1016 | + | el.removeAttribute('data-activity'); | |||
| 1017 | + | }, 60000); | |||
| 1018 | + | } | |||
| 1010 | const old = el.id && document.getElementById(el.id); | 1019 | const old = el.id && document.getElementById(el.id); | ||
| 1011 | const had = old ? where(old) : null; // the focus is inside what is replaced | 1020 | const had = old ? where(old) : null; // the focus is inside what is replaced | ||
| 1012 | if (el.hasAttribute('data-gone')) { | 1021 | if (el.hasAttribute('data-gone')) { | ||
M internal/adapters/web/summary.go +53 -29
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "html/template" | |
| 11 | 11 | "io" | |
| 12 | 12 | "net/http" | |
| 13 | + | "net/url" | |
| 13 | 14 | "strings" | |
| 14 | 15 | "time" | |
| 15 | 16 | ||
| @@ -59,22 +60,23 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||
| 59 | 60 | return nil, 0, err | |
| 60 | 61 | } | |
| 61 | 62 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 62 | - | for i, c := range commits.Commits { | |
| 63 | + | for _, c := range commits.Commits { | |
| 63 | 64 | id := c.ID.String() | |
| 64 | 65 | row := commitRow(d.name(), c, now) | |
| 65 | - | row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0 | |
| 66 | + | row.Branches, row.Tags = branchesAt[id], tagsAt[id] | |
| 66 | 67 | p.Recent = append(p.Recent, row) | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 70 | + | p.License = s.repositoryLicense(ctx, d.repo.Name, d.head.CommitID.String()) | |
| 69 | 71 | p.Facts = s.facts(d, now) | |
| 70 | 72 | p.Latest = s.latest(ctx, d) | |
| 71 | 73 | p.Release = s.summaryRelease(ctx, d.repo.Name, now) | |
| 72 | 74 | p.Actions = s.recentActions(ctx, d.repo.Name, now) | |
| 73 | 75 | ||
| 74 | - | if rd, err := s.readme(ctx, d, ref); err != nil { | |
| 76 | + | if rd, tabs, err := s.summaryDoc(ctx, d, ref, r.URL.Query().Get("doc")); err != nil { | |
| 75 | 77 | return nil, 0, err | |
| 76 | 78 | } else if rd != nil { | |
| 77 | - | p.Readme, p.Outline = rd.readme, rd.outline | |
| 79 | + | p.Readme, p.Outline, p.Docs = rd.readme, rd.outline, tabs | |
| 78 | 80 | } | |
| 79 | 81 | ||
| 80 | 82 | l := s.repoLayout(ctx, d, "summary") | |
| @@ -147,40 +149,62 @@ type renderedReadme struct { | |||
| 147 | 149 | outline []Heading | |
| 148 | 150 | } | |
| 149 | 151 | ||
| 150 | - | // readme finds the README at the root of the tree of the head commit, reads | |
| 151 | - | // it and renders it. It is nil when the repository has none. Rendering | |
| 152 | - | // problems degrade to plain text; only use case failures are errors. | |
| 153 | - | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | |
| 152 | + | // summaryDoc renders only a listed root document; unknown selections use the first. | |
| 153 | + | func (s *Server) summaryDoc(ctx context.Context, d *repoData, ref, selected string) (*renderedReadme, []DocTab, error) { | |
| 154 | 154 | rev := d.head.CommitID.String() | |
| 155 | 155 | tree, err := s.getTree(ctx, d.repo.Name, rev, "", false) | |
| 156 | 156 | if err != nil { | |
| 157 | - | return nil, err | |
| 157 | + | return nil, nil, err | |
| 158 | + | } | |
| 159 | + | entries := summaryDocs(tree.Entries) | |
| 160 | + | if len(entries) == 0 { | |
| 161 | + | return nil, nil, nil | |
| 158 | 162 | } | |
| 159 | - | return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries) | |
| 163 | + | index := 0 | |
| 164 | + | for i, e := range entries { | |
| 165 | + | if e.Name == selected { | |
| 166 | + | index = i | |
| 167 | + | break | |
| 168 | + | } | |
| 169 | + | if strings.EqualFold(e.Name, selected) { | |
| 170 | + | index = i | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | tabs := make([]DocTab, 0, len(entries)) | |
| 174 | + | for i, e := range entries { | |
| 175 | + | tabs = append(tabs, DocTab{Name: e.Name, Href: "?doc=" + url.QueryEscape(e.Name), Current: i == index}) | |
| 176 | + | } | |
| 177 | + | rd, err := s.renderDoc(ctx, d.repo.Name, rev, ref, "", entries[index]) | |
| 178 | + | return rd, tabs, err | |
| 160 | 179 | } | |
| 161 | 180 | ||
| 162 | - | // readmeIn picks the README among the entries of the directory dir ("" for | |
| 163 | - | // the root) of the commit rev, reads it and renders it. Links in it are | |
| 164 | - | // relative to dir, on the ref ref. It is nil when the directory has none. | |
| 165 | - | // | |
| 166 | - | //hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only | |
| 167 | - | func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) { | |
| 168 | - | var best *domain.TreeEntry | |
| 169 | - | rank := len(readmeNames) | |
| 170 | - | for i := range entries { | |
| 171 | - | e := &entries[i] | |
| 172 | - | if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable { | |
| 173 | - | continue | |
| 174 | - | } | |
| 175 | - | for i, n := range readmeNames { | |
| 176 | - | if i < rank && strings.EqualFold(e.Name, n) { | |
| 177 | - | best, rank = e, i | |
| 181 | + | // summaryDocs lists the preferred README, then the common project documents. | |
| 182 | + | func summaryDocs(entries []domain.TreeEntry) []domain.TreeEntry { | |
| 183 | + | var docs []domain.TreeEntry | |
| 184 | + | groups := [][]string{readmeNames} | |
| 185 | + | for _, base := range []string{"license", "licence", "copying", "contributing", "code_of_conduct", "security"} { | |
| 186 | + | groups = append(groups, []string{base, base + ".md", base + ".markdown", base + ".txt"}) | |
| 187 | + | } | |
| 188 | + | for g, names := range groups { | |
| 189 | + | for _, name := range names { | |
| 190 | + | for _, e := range entries { | |
| 191 | + | if (e.Kind == domain.EntryFile || e.Kind == domain.EntryExecutable) && strings.EqualFold(e.Name, name) { | |
| 192 | + | docs = append(docs, e) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | if g == 0 && len(docs) > 0 { | |
| 196 | + | break | |
| 178 | 197 | } | |
| 179 | 198 | } | |
| 180 | 199 | } | |
| 181 | - | if best == nil { | |
| 182 | - | return nil, nil | |
| 183 | - | } | |
| 200 | + | return docs | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // renderDoc shares the README limits and rendering with the summary documents. | |
| 204 | + | // | |
| 205 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads and renders a root document to show; display only | |
| 206 | + | func (s *Server) renderDoc(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entry domain.TreeEntry) (*renderedReadme, error) { | |
| 207 | + | best := &entry | |
| 184 | 208 | rd := &Readme{Name: best.Name} | |
| 185 | 209 | if best.Size > maxReadmeBytes { | |
| 186 | 210 | rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")." | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "html/template" | 10 | "html/template" | ||
| 11 | "io" | 11 | "io" | ||
| 12 | "net/http" | 12 | "net/http" | ||
| 13 | + | "net/url" | |||
| 13 | "strings" | 14 | "strings" | ||
| 14 | "time" | 15 | "time" | ||
| 15 | 16 | ||||
| @@ -59,22 +60,23 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 59 | return nil, 0, err | 60 | return nil, 0, err | ||
| 60 | } | 61 | } | ||
| 61 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | 62 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | ||
| 62 | - | for i, c := range commits.Commits { | 63 | + | for _, c := range commits.Commits { |
| 63 | id := c.ID.String() | 64 | id := c.ID.String() | ||
| 64 | row := commitRow(d.name(), c, now) | 65 | row := commitRow(d.name(), c, now) | ||
| 65 | - | row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0 | 66 | + | row.Branches, row.Tags = branchesAt[id], tagsAt[id] |
| 66 | p.Recent = append(p.Recent, row) | 67 | p.Recent = append(p.Recent, row) | ||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 70 | + | p.License = s.repositoryLicense(ctx, d.repo.Name, d.head.CommitID.String()) | |||
| 69 | p.Facts = s.facts(d, now) | 71 | p.Facts = s.facts(d, now) | ||
| 70 | p.Latest = s.latest(ctx, d) | 72 | p.Latest = s.latest(ctx, d) | ||
| 71 | p.Release = s.summaryRelease(ctx, d.repo.Name, now) | 73 | p.Release = s.summaryRelease(ctx, d.repo.Name, now) | ||
| 72 | p.Actions = s.recentActions(ctx, d.repo.Name, now) | 74 | p.Actions = s.recentActions(ctx, d.repo.Name, now) | ||
| 73 | 75 | ||||
| 74 | - | if rd, err := s.readme(ctx, d, ref); err != nil { | 76 | + | if rd, tabs, err := s.summaryDoc(ctx, d, ref, r.URL.Query().Get("doc")); err != nil { |
| 75 | return nil, 0, err | 77 | return nil, 0, err | ||
| 76 | } else if rd != nil { | 78 | } else if rd != nil { | ||
| 77 | - | p.Readme, p.Outline = rd.readme, rd.outline | 79 | + | p.Readme, p.Outline, p.Docs = rd.readme, rd.outline, tabs |
| 78 | } | 80 | } | ||
| 79 | 81 | ||||
| 80 | l := s.repoLayout(ctx, d, "summary") | 82 | l := s.repoLayout(ctx, d, "summary") | ||
| @@ -147,40 +149,62 @@ type renderedReadme struct { | |||||
| 147 | outline []Heading | 149 | outline []Heading | ||
| 148 | } | 150 | } | ||
| 149 | 151 | ||||
| 150 | - | // readme finds the README at the root of the tree of the head commit, reads | 152 | + | // summaryDoc renders only a listed root document; unknown selections use the first. |
| 151 | - | // it and renders it. It is nil when the repository has none. Rendering | 153 | + | func (s *Server) summaryDoc(ctx context.Context, d *repoData, ref, selected string) (*renderedReadme, []DocTab, error) { |
| 152 | - | // problems degrade to plain text; only use case failures are errors. | |||
| 153 | - | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | |||
| 154 | rev := d.head.CommitID.String() | 154 | rev := d.head.CommitID.String() | ||
| 155 | tree, err := s.getTree(ctx, d.repo.Name, rev, "", false) | 155 | tree, err := s.getTree(ctx, d.repo.Name, rev, "", false) | ||
| 156 | if err != nil { | 156 | if err != nil { | ||
| 157 | - | return nil, err | 157 | + | return nil, nil, err |
| 158 | + | } | |||
| 159 | + | entries := summaryDocs(tree.Entries) | |||
| 160 | + | if len(entries) == 0 { | |||
| 161 | + | return nil, nil, nil | |||
| 158 | } | 162 | } | ||
| 159 | - | return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries) | 163 | + | index := 0 |
| 164 | + | for i, e := range entries { | |||
| 165 | + | if e.Name == selected { | |||
| 166 | + | index = i | |||
| 167 | + | break | |||
| 168 | + | } | |||
| 169 | + | if strings.EqualFold(e.Name, selected) { | |||
| 170 | + | index = i | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | tabs := make([]DocTab, 0, len(entries)) | |||
| 174 | + | for i, e := range entries { | |||
| 175 | + | tabs = append(tabs, DocTab{Name: e.Name, Href: "?doc=" + url.QueryEscape(e.Name), Current: i == index}) | |||
| 176 | + | } | |||
| 177 | + | rd, err := s.renderDoc(ctx, d.repo.Name, rev, ref, "", entries[index]) | |||
| 178 | + | return rd, tabs, err | |||
| 160 | } | 179 | } | ||
| 161 | 180 | ||||
| 162 | - | // readmeIn picks the README among the entries of the directory dir ("" for | 181 | + | // summaryDocs lists the preferred README, then the common project documents. |
| 163 | - | // the root) of the commit rev, reads it and renders it. Links in it are | 182 | + | func summaryDocs(entries []domain.TreeEntry) []domain.TreeEntry { |
| 164 | - | // relative to dir, on the ref ref. It is nil when the directory has none. | 183 | + | var docs []domain.TreeEntry |
| 165 | - | // | 184 | + | groups := [][]string{readmeNames} |
| 166 | - | //hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only | 185 | + | for _, base := range []string{"license", "licence", "copying", "contributing", "code_of_conduct", "security"} { |
| 167 | - | func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) { | 186 | + | groups = append(groups, []string{base, base + ".md", base + ".markdown", base + ".txt"}) |
| 168 | - | var best *domain.TreeEntry | 187 | + | } |
| 169 | - | rank := len(readmeNames) | 188 | + | for g, names := range groups { |
| 170 | - | for i := range entries { | 189 | + | for _, name := range names { |
| 171 | - | e := &entries[i] | 190 | + | for _, e := range entries { |
| 172 | - | if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable { | 191 | + | if (e.Kind == domain.EntryFile || e.Kind == domain.EntryExecutable) && strings.EqualFold(e.Name, name) { |
| 173 | - | continue | 192 | + | docs = append(docs, e) |
| 174 | - | } | 193 | + | } |
| 175 | - | for i, n := range readmeNames { | 194 | + | } |
| 176 | - | if i < rank && strings.EqualFold(e.Name, n) { | 195 | + | if g == 0 && len(docs) > 0 { |
| 177 | - | best, rank = e, i | 196 | + | break |
| 178 | } | 197 | } | ||
| 179 | } | 198 | } | ||
| 180 | } | 199 | } | ||
| 181 | - | if best == nil { | 200 | + | return docs |
| 182 | - | return nil, nil | 201 | + | } |
| 183 | - | } | 202 | + | |
| 203 | + | // renderDoc shares the README limits and rendering with the summary documents. | |||
| 204 | + | // | |||
| 205 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads and renders a root document to show; display only | |||
| 206 | + | func (s *Server) renderDoc(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entry domain.TreeEntry) (*renderedReadme, error) { | |||
| 207 | + | best := &entry | |||
| 184 | rd := &Readme{Name: best.Name} | 208 | rd := &Readme{Name: best.Name} | ||
| 185 | if best.Size > maxReadmeBytes { | 209 | if best.Size > maxReadmeBytes { | ||
| 186 | rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")." | 210 | rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")." | ||
A internal/adapters/web/summary_docs_test.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func TestSummaryDocs(t *testing.T) { | |
| 23 | + | t.Parallel() | |
| 24 | + | entries := []domain.TreeEntry{ | |
| 25 | + | {Name: "readme.txt", Kind: domain.EntryFile}, | |
| 26 | + | {Name: "SECURITY.md", Kind: domain.EntryFile}, | |
| 27 | + | {Name: "LICENSE", Kind: domain.EntryExecutable}, | |
| 28 | + | {Name: "README.md", Kind: domain.EntryFile}, | |
| 29 | + | {Name: "CONTRIBUTING.md", Kind: domain.EntryDirectory}, | |
| 30 | + | {Name: "COPYING.txt", Kind: domain.EntryFile}, | |
| 31 | + | {Name: "code_of_conduct.MD", Kind: domain.EntryFile}, | |
| 32 | + | } | |
| 33 | + | docs := summaryDocs(entries) | |
| 34 | + | var names []string | |
| 35 | + | for _, e := range docs { | |
| 36 | + | names = append(names, e.Name) | |
| 37 | + | } | |
| 38 | + | assert.Equal(t, []string{"README.md", "LICENSE", "COPYING.txt", "code_of_conduct.MD", "SECURITY.md"}, names) | |
| 39 | + | assert.Empty(t, summaryDocs(nil)) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestSummaryDocSelection(t *testing.T) { | |
| 43 | + | t.Parallel() | |
| 44 | + | for _, tc := range []struct { | |
| 45 | + | selected, want, body string | |
| 46 | + | markdown, noReadme bool | |
| 47 | + | }{ | |
| 48 | + | {"", "README.md", "# Read me", true, false}, | |
| 49 | + | {"security.MD", "SECURITY.md", "# Reporting", true, false}, | |
| 50 | + | {"LICENSE", "LICENSE", "<license>", false, false}, | |
| 51 | + | {"../secret", "README.md", "# Read me", true, false}, | |
| 52 | + | {"", "LICENSE", "<license>", false, true}, | |
| 53 | + | } { | |
| 54 | + | t.Run(tc.selected+tc.want, func(t *testing.T) { | |
| 55 | + | t.Parallel() | |
| 56 | + | repo := repoName(t, "r") | |
| 57 | + | browse := mocks.NewBrowseService(t) | |
| 58 | + | entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "SECURITY.md", Path: "SECURITY.md", Kind: domain.EntryFile}} | |
| 59 | + | if !tc.noReadme { | |
| 60 | + | entries = append(entries, domain.TreeEntry{Name: "README.md", Path: "README.md", Kind: domain.EntryFile}) | |
| 61 | + | } | |
| 62 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil) | |
| 63 | + | browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |
| 64 | + | assert.Equal(t, tc.want, path.String()) | |
| 65 | + | return domain.BlobInfo{}, io.NopCloser(strings.NewReader(tc.body)), nil | |
| 66 | + | }) | |
| 67 | + | s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse}) | |
| 68 | + | d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}} | |
| 69 | + | rd, tabs, err := s.summaryDoc(t.Context(), d, "main", tc.selected) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | require.NotNil(t, rd) | |
| 72 | + | assert.Equal(t, tc.want, rd.readme.Name) | |
| 73 | + | if tc.markdown { | |
| 74 | + | assert.Contains(t, string(rd.readme.HTML), "<h1") | |
| 75 | + | assert.Len(t, rd.outline, 1) | |
| 76 | + | } else { | |
| 77 | + | assert.Equal(t, "<pre><license></pre>", string(rd.readme.HTML)) | |
| 78 | + | assert.Empty(t, rd.outline) | |
| 79 | + | } | |
| 80 | + | current := 0 | |
| 81 | + | for _, tab := range tabs { | |
| 82 | + | if tab.Current { | |
| 83 | + | current++ | |
| 84 | + | assert.Equal(t, tc.want, tab.Name) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | assert.Equal(t, 1, current) | |
| 88 | + | out := renderToString(t, ReadmeCard(*rd.readme, tabs...)) | |
| 89 | + | assert.Contains(t, out, `aria-label="Repository documents"`) | |
| 90 | + | assert.Contains(t, out, `aria-current="page"`) | |
| 91 | + | assert.Contains(t, out, `href="?doc=LICENSE"`) | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestSummaryDocLimits(t *testing.T) { | |
| 97 | + | t.Parallel() | |
| 98 | + | s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)}) | |
| 99 | + | rd, err := s.renderDoc(t.Context(), repoName(t, "r"), mainTip, "main", "", domain.TreeEntry{Name: "LICENSE", Size: maxReadmeBytes + 1}) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | assert.Contains(t, rd.readme.Note, "too large") | |
| 102 | + | assert.Empty(t, rd.outline) | |
| 103 | + | out := renderToString(t, ReadmeCard(*rd.readme)) | |
| 104 | + | assert.NotContains(t, out, "doc-tabs") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestSummaryArrivalHasNoRailCursor(t *testing.T) { | |
| 108 | + | t.Parallel() | |
| 109 | + | repo := repoName(t, "r") | |
| 110 | + | browse := mocks.NewBrowseService(t) | |
| 111 | + | tip := objectID(t, mainTip) | |
| 112 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: tip}}}, nil) | |
| 113 | + | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: []domain.Commit{{ID: tip, Summary: "recent commit"}}}, nil) | |
| 114 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil) | |
| 115 | + | s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) | |
| 116 | + | req := httptest.NewRequest(http.MethodGet, "/r", nil) | |
| 117 | + | req.SetPathValue("repo", "r") | |
| 118 | + | page, status, err := s.summary(t.Context(), req) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | assert.Equal(t, http.StatusOK, status) | |
| 121 | + | out := renderToString(t, page) | |
| 122 | + | assert.Contains(t, out, "recent commit") | |
| 123 | + | _, rail, found := strings.Cut(out, `<aside class="rail"`) | |
| 124 | + | require.True(t, found) | |
| 125 | + | rail, _, _ = strings.Cut(rail, "</aside>") | |
| 126 | + | assert.NotContains(t, rail, "data-cursor") | |
| 127 | + | assert.NotContains(t, rail, "autofocus") | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestSummaryDocExactFilename(t *testing.T) { | |
| 131 | + | t.Parallel() | |
| 132 | + | for _, selected := range []string{"LICENSE", "license"} { | |
| 133 | + | t.Run(selected, func(t *testing.T) { | |
| 134 | + | t.Parallel() | |
| 135 | + | repo := repoName(t, "r") | |
| 136 | + | browse := mocks.NewBrowseService(t) | |
| 137 | + | entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "license", Path: "license", Kind: domain.EntryFile}} | |
| 138 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil) | |
| 139 | + | browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |
| 140 | + | return domain.BlobInfo{}, io.NopCloser(strings.NewReader("content of " + path.String())), nil | |
| 141 | + | }) | |
| 142 | + | s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse}) | |
| 143 | + | d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}} | |
| 144 | + | req := httptest.NewRequest(http.MethodGet, "/r?doc="+selected, nil) | |
| 145 | + | rd, tabs, err := s.summaryDoc(t.Context(), d, "main", req.URL.Query().Get("doc")) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | require.NotNil(t, rd) | |
| 148 | + | assert.Equal(t, selected, rd.readme.Name) | |
| 149 | + | assert.Equal(t, "<pre>content of "+selected+"</pre>", string(rd.readme.HTML)) | |
| 150 | + | require.Len(t, tabs, 2) | |
| 151 | + | for _, tab := range tabs { | |
| 152 | + | assert.Equal(t, tab.Name == selected, tab.Current, tab.Name) | |
| 153 | + | assert.Equal(t, "?doc="+tab.Name, tab.Href) | |
| 154 | + | } | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | } | |
| @@ -0,0 +1,157 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/http/httptest" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | func TestSummaryDocs(t *testing.T) { | |||
| 23 | + | t.Parallel() | |||
| 24 | + | entries := []domain.TreeEntry{ | |||
| 25 | + | {Name: "readme.txt", Kind: domain.EntryFile}, | |||
| 26 | + | {Name: "SECURITY.md", Kind: domain.EntryFile}, | |||
| 27 | + | {Name: "LICENSE", Kind: domain.EntryExecutable}, | |||
| 28 | + | {Name: "README.md", Kind: domain.EntryFile}, | |||
| 29 | + | {Name: "CONTRIBUTING.md", Kind: domain.EntryDirectory}, | |||
| 30 | + | {Name: "COPYING.txt", Kind: domain.EntryFile}, | |||
| 31 | + | {Name: "code_of_conduct.MD", Kind: domain.EntryFile}, | |||
| 32 | + | } | |||
| 33 | + | docs := summaryDocs(entries) | |||
| 34 | + | var names []string | |||
| 35 | + | for _, e := range docs { | |||
| 36 | + | names = append(names, e.Name) | |||
| 37 | + | } | |||
| 38 | + | assert.Equal(t, []string{"README.md", "LICENSE", "COPYING.txt", "code_of_conduct.MD", "SECURITY.md"}, names) | |||
| 39 | + | assert.Empty(t, summaryDocs(nil)) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestSummaryDocSelection(t *testing.T) { | |||
| 43 | + | t.Parallel() | |||
| 44 | + | for _, tc := range []struct { | |||
| 45 | + | selected, want, body string | |||
| 46 | + | markdown, noReadme bool | |||
| 47 | + | }{ | |||
| 48 | + | {"", "README.md", "# Read me", true, false}, | |||
| 49 | + | {"security.MD", "SECURITY.md", "# Reporting", true, false}, | |||
| 50 | + | {"LICENSE", "LICENSE", "<license>", false, false}, | |||
| 51 | + | {"../secret", "README.md", "# Read me", true, false}, | |||
| 52 | + | {"", "LICENSE", "<license>", false, true}, | |||
| 53 | + | } { | |||
| 54 | + | t.Run(tc.selected+tc.want, func(t *testing.T) { | |||
| 55 | + | t.Parallel() | |||
| 56 | + | repo := repoName(t, "r") | |||
| 57 | + | browse := mocks.NewBrowseService(t) | |||
| 58 | + | entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "SECURITY.md", Path: "SECURITY.md", Kind: domain.EntryFile}} | |||
| 59 | + | if !tc.noReadme { | |||
| 60 | + | entries = append(entries, domain.TreeEntry{Name: "README.md", Path: "README.md", Kind: domain.EntryFile}) | |||
| 61 | + | } | |||
| 62 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil) | |||
| 63 | + | browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 64 | + | assert.Equal(t, tc.want, path.String()) | |||
| 65 | + | return domain.BlobInfo{}, io.NopCloser(strings.NewReader(tc.body)), nil | |||
| 66 | + | }) | |||
| 67 | + | s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse}) | |||
| 68 | + | d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}} | |||
| 69 | + | rd, tabs, err := s.summaryDoc(t.Context(), d, "main", tc.selected) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | require.NotNil(t, rd) | |||
| 72 | + | assert.Equal(t, tc.want, rd.readme.Name) | |||
| 73 | + | if tc.markdown { | |||
| 74 | + | assert.Contains(t, string(rd.readme.HTML), "<h1") | |||
| 75 | + | assert.Len(t, rd.outline, 1) | |||
| 76 | + | } else { | |||
| 77 | + | assert.Equal(t, "<pre><license></pre>", string(rd.readme.HTML)) | |||
| 78 | + | assert.Empty(t, rd.outline) | |||
| 79 | + | } | |||
| 80 | + | current := 0 | |||
| 81 | + | for _, tab := range tabs { | |||
| 82 | + | if tab.Current { | |||
| 83 | + | current++ | |||
| 84 | + | assert.Equal(t, tc.want, tab.Name) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | assert.Equal(t, 1, current) | |||
| 88 | + | out := renderToString(t, ReadmeCard(*rd.readme, tabs...)) | |||
| 89 | + | assert.Contains(t, out, `aria-label="Repository documents"`) | |||
| 90 | + | assert.Contains(t, out, `aria-current="page"`) | |||
| 91 | + | assert.Contains(t, out, `href="?doc=LICENSE"`) | |||
| 92 | + | }) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestSummaryDocLimits(t *testing.T) { | |||
| 97 | + | t.Parallel() | |||
| 98 | + | s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)}) | |||
| 99 | + | rd, err := s.renderDoc(t.Context(), repoName(t, "r"), mainTip, "main", "", domain.TreeEntry{Name: "LICENSE", Size: maxReadmeBytes + 1}) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | assert.Contains(t, rd.readme.Note, "too large") | |||
| 102 | + | assert.Empty(t, rd.outline) | |||
| 103 | + | out := renderToString(t, ReadmeCard(*rd.readme)) | |||
| 104 | + | assert.NotContains(t, out, "doc-tabs") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestSummaryArrivalHasNoRailCursor(t *testing.T) { | |||
| 108 | + | t.Parallel() | |||
| 109 | + | repo := repoName(t, "r") | |||
| 110 | + | browse := mocks.NewBrowseService(t) | |||
| 111 | + | tip := objectID(t, mainTip) | |||
| 112 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: tip}}}, nil) | |||
| 113 | + | browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: []domain.Commit{{ID: tip, Summary: "recent commit"}}}, nil) | |||
| 114 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil) | |||
| 115 | + | s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) | |||
| 116 | + | req := httptest.NewRequest(http.MethodGet, "/r", nil) | |||
| 117 | + | req.SetPathValue("repo", "r") | |||
| 118 | + | page, status, err := s.summary(t.Context(), req) | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | assert.Equal(t, http.StatusOK, status) | |||
| 121 | + | out := renderToString(t, page) | |||
| 122 | + | assert.Contains(t, out, "recent commit") | |||
| 123 | + | _, rail, found := strings.Cut(out, `<aside class="rail"`) | |||
| 124 | + | require.True(t, found) | |||
| 125 | + | rail, _, _ = strings.Cut(rail, "</aside>") | |||
| 126 | + | assert.NotContains(t, rail, "data-cursor") | |||
| 127 | + | assert.NotContains(t, rail, "autofocus") | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestSummaryDocExactFilename(t *testing.T) { | |||
| 131 | + | t.Parallel() | |||
| 132 | + | for _, selected := range []string{"LICENSE", "license"} { | |||
| 133 | + | t.Run(selected, func(t *testing.T) { | |||
| 134 | + | t.Parallel() | |||
| 135 | + | repo := repoName(t, "r") | |||
| 136 | + | browse := mocks.NewBrowseService(t) | |||
| 137 | + | entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "license", Path: "license", Kind: domain.EntryFile}} | |||
| 138 | + | browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil) | |||
| 139 | + | browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 140 | + | return domain.BlobInfo{}, io.NopCloser(strings.NewReader("content of " + path.String())), nil | |||
| 141 | + | }) | |||
| 142 | + | s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse}) | |||
| 143 | + | d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}} | |||
| 144 | + | req := httptest.NewRequest(http.MethodGet, "/r?doc="+selected, nil) | |||
| 145 | + | rd, tabs, err := s.summaryDoc(t.Context(), d, "main", req.URL.Query().Get("doc")) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | require.NotNil(t, rd) | |||
| 148 | + | assert.Equal(t, selected, rd.readme.Name) | |||
| 149 | + | assert.Equal(t, "<pre>content of "+selected+"</pre>", string(rd.readme.HTML)) | |||
| 150 | + | require.Len(t, tabs, 2) | |||
| 151 | + | for _, tab := range tabs { | |||
| 152 | + | assert.Equal(t, tab.Name == selected, tab.Current, tab.Name) | |||
| 153 | + | assert.Equal(t, "?doc="+tab.Name, tab.Href) | |||
| 154 | + | } | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
M internal/adapters/web/view.go +14 -4
| @@ -84,10 +84,12 @@ type Link struct{ Label, Href string } | |||
| 84 | 84 | ||
| 85 | 85 | // HomeRow is one repository in the home list. | |
| 86 | 86 | type HomeRow struct { | |
| 87 | - | ID string | |
| 88 | - | Name string | |
| 89 | - | Href string | |
| 90 | - | Desc string | |
| 87 | + | Release *LatestRelease | |
| 88 | + | License *LicenseBadge | |
| 89 | + | ID string | |
| 90 | + | Name string | |
| 91 | + | Href string | |
| 92 | + | Desc string | |
| 91 | 93 | // Topics classify the repository, as on GitHub. | |
| 92 | 94 | Topics []string | |
| 93 | 95 | // Active is when the repository last changed (its last push or merge), | |
| @@ -201,8 +203,16 @@ type CommitRow struct { | |||
| 201 | 203 | Sig *SignatureBadge | |
| 202 | 204 | } | |
| 203 | 205 | ||
| 206 | + | // DocTab links to a root document on the summary. | |
| 207 | + | type DocTab struct { | |
| 208 | + | Name, Href string | |
| 209 | + | Current bool | |
| 210 | + | } | |
| 211 | + | ||
| 204 | 212 | // SummaryPage is the repository summary. | |
| 205 | 213 | type SummaryPage struct { | |
| 214 | + | Docs []DocTab | |
| 215 | + | License *LicenseBadge | |
| 206 | 216 | Mirror *MirrorBadge | |
| 207 | 217 | Facts []Fact | |
| 208 | 218 | Clone Clone | |
| @@ -84,10 +84,12 @@ type Link struct{ Label, Href string } | |||||
| 84 | 84 | ||||
| 85 | // HomeRow is one repository in the home list. | 85 | // HomeRow is one repository in the home list. | ||
| 86 | type HomeRow struct { | 86 | type HomeRow struct { | ||
| 87 | - | ID string | 87 | + | Release *LatestRelease |
| 88 | - | Name string | 88 | + | License *LicenseBadge |
| 89 | - | Href string | 89 | + | ID string |
| 90 | - | Desc string | 90 | + | Name string |
| 91 | + | Href string | |||
| 92 | + | Desc string | |||
| 91 | // Topics classify the repository, as on GitHub. | 93 | // Topics classify the repository, as on GitHub. | ||
| 92 | Topics []string | 94 | Topics []string | ||
| 93 | // Active is when the repository last changed (its last push or merge), | 95 | // Active is when the repository last changed (its last push or merge), | ||
| @@ -201,8 +203,16 @@ type CommitRow struct { | |||||
| 201 | Sig *SignatureBadge | 203 | Sig *SignatureBadge | ||
| 202 | } | 204 | } | ||
| 203 | 205 | ||||
| 206 | + | // DocTab links to a root document on the summary. | |||
| 207 | + | type DocTab struct { | |||
| 208 | + | Name, Href string | |||
| 209 | + | Current bool | |||
| 210 | + | } | |||
| 211 | + | ||||
| 204 | // SummaryPage is the repository summary. | 212 | // SummaryPage is the repository summary. | ||
| 205 | type SummaryPage struct { | 213 | type SummaryPage struct { | ||
| 214 | + | Docs []DocTab | |||
| 215 | + | License *LicenseBadge | |||
| 206 | Mirror *MirrorBadge | 216 | Mirror *MirrorBadge | ||
| 207 | Facts []Fact | 217 | Facts []Fact | ||
| 208 | Clone Clone | 218 | Clone Clone | ||
M internal/adapters/web/web.go +3 -0
| @@ -139,6 +139,8 @@ type Server struct { | |||
| 139 | 139 | md *markdown | |
| 140 | 140 | // pathCache holds the path lists the file finder fetches. | |
| 141 | 141 | pathCache *pathCache | |
| 142 | + | // licenses holds small display results, including missing licenses. | |
| 143 | + | licenses *pathCache | |
| 142 | 144 | // hl holds the colours of highlighted diffs. | |
| 143 | 145 | hl *hlCache | |
| 144 | 146 | mux *http.ServeMux | |
| @@ -174,6 +176,7 @@ func New(cfg Config) (*Server, error) { | |||
| 174 | 176 | s := &Server{ | |
| 175 | 177 | cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(), | |
| 176 | 178 | pathCache: newPathCache(pathCacheBytes, pathBuilds), | |
| 179 | + | licenses: newPathCache(1<<20, homeWorkers), | |
| 177 | 180 | hl: newHLCache(hlCacheBytes), | |
| 178 | 181 | origin: originOf(u), | |
| 179 | 182 | } | |
| @@ -139,6 +139,8 @@ type Server struct { | |||||
| 139 | md *markdown | 139 | md *markdown | ||
| 140 | // pathCache holds the path lists the file finder fetches. | 140 | // pathCache holds the path lists the file finder fetches. | ||
| 141 | pathCache *pathCache | 141 | pathCache *pathCache | ||
| 142 | + | // licenses holds small display results, including missing licenses. | |||
| 143 | + | licenses *pathCache | |||
| 142 | // hl holds the colours of highlighted diffs. | 144 | // hl holds the colours of highlighted diffs. | ||
| 143 | hl *hlCache | 145 | hl *hlCache | ||
| 144 | mux *http.ServeMux | 146 | mux *http.ServeMux | ||
| @@ -174,6 +176,7 @@ func New(cfg Config) (*Server, error) { | |||||
| 174 | s := &Server{ | 176 | s := &Server{ | ||
| 175 | cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(), | 177 | cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(), | ||
| 176 | pathCache: newPathCache(pathCacheBytes, pathBuilds), | 178 | pathCache: newPathCache(pathCacheBytes, pathBuilds), | ||
| 179 | + | licenses: newPathCache(1<<20, homeWorkers), | |||
| 177 | hl: newHLCache(hlCacheBytes), | 180 | hl: newHLCache(hlCacheBytes), | ||
| 178 | origin: originOf(u), | 181 | origin: originOf(u), | ||
| 179 | } | 182 | } | ||
M internal/app/testdata/web/summary.html +2 -2
| @@ -99,7 +99,7 @@ | |||
| 99 | 99 | <p class="lede">A demo repository</p> | |
| 100 | 100 | </section> | |
| 101 | 101 | <main id="main" tabindex="-1"> | |
| 102 | - | <section class="card readme" aria-labelledby="readme-h"> | |
| 102 | + | <section class="card readme" aria-label="README.md"> | |
| 103 | 103 | <h2 class="card-h" id="readme-h"> | |
| 104 | 104 | <span class="ico i-file" aria-hidden="true"> | |
| 105 | 105 | </span> README.md</h2> | |
| @@ -164,7 +164,7 @@ | |||
| 164 | 164 | </h2> | |
| 165 | 165 | <table class="list commits" aria-label="Commits"> | |
| 166 | 166 | <tbody data-rows> | |
| 167 | - | <tr data-cursor> | |
| 167 | + | <tr> | |
| 168 | 168 | <td class="c-sha mute">SHA7</td> | |
| 169 | 169 | <td class="c-subject"> | |
| 170 | 170 | <a href="/demo/commit/SHA40">add main.go</a> | |
| @@ -99,7 +99,7 @@ | |||||
| 99 | <p class="lede">A demo repository</p> | 99 | <p class="lede">A demo repository</p> | ||
| 100 | </section> | 100 | </section> | ||
| 101 | <main id="main" tabindex="-1"> | 101 | <main id="main" tabindex="-1"> | ||
| 102 | - | <section class="card readme" aria-labelledby="readme-h"> | 102 | + | <section class="card readme" aria-label="README.md"> |
| 103 | <h2 class="card-h" id="readme-h"> | 103 | <h2 class="card-h" id="readme-h"> | ||
| 104 | <span class="ico i-file" aria-hidden="true"> | 104 | <span class="ico i-file" aria-hidden="true"> | ||
| 105 | </span> README.md</h2> | 105 | </span> README.md</h2> | ||
| @@ -164,7 +164,7 @@ | |||||
| 164 | </h2> | 164 | </h2> | ||
| 165 | <table class="list commits" aria-label="Commits"> | 165 | <table class="list commits" aria-label="Commits"> | ||
| 166 | <tbody data-rows> | 166 | <tbody data-rows> | ||
| 167 | - | <tr data-cursor> | 167 | + | <tr> |
| 168 | <td class="c-sha mute">SHA7</td> | 168 | <td class="c-sha mute">SHA7</td> | ||
| 169 | <td class="c-subject"> | 169 | <td class="c-subject"> | ||
| 170 | <a href="/demo/commit/SHA40">add main.go</a> | 170 | <a href="/demo/commit/SHA40">add main.go</a> | ||