Commit
Add GitHub-style snapshot experience
commit
bcce357
24 changed files with +2948 and −209
Jump to a changed file
- README.md +15 −5
- src/app.js +413 −14
- src/db.js +59 −4
- src/github.js +263 −26
- src/public/app.js +61 −3
- src/public/styles.css +361 −29
- src/views/created.ejs +10 −1
- src/views/dashboard.ejs +58 −3
- src/views/expired.ejs +8 −3
- src/views/profile.ejs +112 −45
- src/views/repository.ejs +494 −36
- src/views/search.ejs +207 −0
- tests/auth.test.js +15 −0
- tests/file-browser.test.js +46 −0
- tests/github-client.test.js +283 −0
- tests/helpers.js +190 −36
- tests/issues-pulls.test.js +65 −0
- tests/layout.test.js +5 −3
- tests/profile-page.test.js +13 −0
- tests/read-only.test.js +2 −1
- tests/refs.test.js +99 −0
- tests/releases.test.js +54 −0
- tests/search.test.js +34 −0
- tests/share-management.test.js +81 −0
modified README.md +15 −5
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # profileShare |
| 2 | 2 | |
| 3 | -profileShare creates time-limited, read-only snapshots of selected GitHub repositories. Anyone with a valid link can view the profile, files, and commit history without a GitHub account. | |
| 3 | +profileShare creates time-limited, read-only snapshots of selected GitHub repositories. Anyone with a valid link can search the snapshot, switch frozen branches and tags, browse repositories and files, preview Markdown and raster images, open line-linked source, review file history, inspect commit diffs, read issues and pull request reviews, and inspect releases without a GitHub account. | |
| 4 | 4 | |
| 5 | 5 | ## Requirements |
| 6 | 6 | |
| @@ -31,7 +31,9 @@node --version | ||
| 31 | 31 | | Setup URL | `http://localhost:3000/github/installed` | |
| 32 | 32 | | Webhook | Clear **Active** | |
| 33 | 33 | | Repository permissions: Contents | Read-only | |
| 34 | + | Repository permissions: Issues | Read-only | | |
| 34 | 35 | | Repository permissions: Metadata | Read-only | |
| 36 | + | Repository permissions: Pull requests | Read-only | | |
| 35 | 37 | | Where can this GitHub App be installed? | Only on this account | |
| 36 | 38 | |
| 37 | 39 | No webhook secret, private key, or user permission is needed. Keep **Request user authorization (OAuth) during installation** cleared because profileShare authorizes the owner before starting installation. |
| @@ -89,7 +91,7 @@npm run dev | ||
| 89 | 91 | |
| 90 | 92 | For a deployed instance, set `NODE_ENV=production`, set `BASE_URL` to its HTTPS origin, and update the GitHub App callback and setup URLs to the same origin. Generated links use `BASE_URL`, so it must be reachable by viewers. |
| 91 | 93 | |
| 92 | -Snapshots are stored in the SQLite database. When an expired link is opened, its stored snapshot content is removed and the expired message is shown. | |
| 94 | +Snapshots are stored in the SQLite database. The owner workspace lists created links and lets the signed-in owner copy, open, or revoke active links. Revoking a link removes its stored repository content. When an expired link is opened, its stored snapshot content is removed and the expired message is shown. | |
| 93 | 95 | |
| 94 | 96 | ## Verify |
| 95 | 97 | |
| @@ -112,15 +114,23 @@Manual acceptance check: | ||
| 112 | 114 | 1. Create a link with a known set of repositories. |
| 113 | 115 | 2. Open it in a private browser window and confirm no viewer login is requested. |
| 114 | 116 | 3. Confirm only the selected repositories appear. |
| 115 | -4. Browse a nested folder, open a file, and open a commit diff. | |
| 116 | -5. Confirm the full commit list appears for each selected repository. | |
| 117 | -6. Change a source repository and confirm the existing link remains unchanged. | |
| 117 | +4. Search for a file or code phrase across the snapshot. | |
| 118 | +5. Switch between a repository's branches and tags, and confirm each ref shows its frozen files and commits. | |
| 119 | +6. Open the Issues and Pull requests tabs, then inspect a conversation and a changed-file diff. | |
| 120 | +7. Open Releases, inspect the release notes and asset metadata, and browse its tag. | |
| 121 | +8. Confirm the repository overview shows topics, license, languages, stars, forks, branches, and tags. | |
| 122 | +9. Browse a nested folder, preview a Markdown file and a raster image, switch Markdown to source, follow a line link, and open file history. | |
| 123 | +10. Open a commit diff and confirm the full commit list appears for each selected repository. | |
| 124 | +11. Filter the profile's repository tab by name and language. | |
| 125 | +12. Change a source repository and confirm the existing link remains unchanged. | |
| 126 | +13. Return to the owner workspace, revoke the link, and confirm the public URL no longer exposes repository content. | |
| 118 | 127 | |
| 119 | 128 | ## Troubleshooting |
| 120 | 129 | |
| 121 | 130 | - **GitHub is not configured:** Confirm all three `GITHUB_*` values are present in `.env`, then restart the server. |
| 122 | 131 | - **Callback error or sign-in verification error:** Make sure `BASE_URL` and the GitHub App callback URL use the same origin, port, and protocol. Start sign-in again from the home page. |
| 123 | 132 | - **No repositories appear:** Open the GitHub App installation settings and grant it access to the intended repositories. The app lists only repositories selected for that installation. |
| 133 | +- **Issues or pull requests do not appear:** Grant the GitHub App read-only Issues and Pull requests permissions, approve the updated installation permissions, then create a new snapshot. | |
| 124 | 134 | - **Repository snapshot fails:** Confirm the GitHub App has read-only **Contents** and **Metadata** repository permissions and that the owner still has access. |
| 125 | 135 | - **Stored token errors after changing `SESSION_SECRET`:** Restore the previous secret, or move the local database aside and sign in again. Do not delete a database that contains snapshots you need. |
| 126 | 136 | - **The generated link points to localhost:** Set `BASE_URL` to the viewer-reachable HTTPS origin before creating the link. |
modified src/app.js +413 −14
| @@ -35,6 +35,169 @@function foldersFor(files, currentPath) { | ||
| 35 | 35 | return { folders: [...folders].sort(), files: directFiles.sort((a, b) => a.path.localeCompare(b.path)) }; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | +function isMarkdownFile(path) { | |
| 39 | + return /\.(?:md|markdown|mdown|mkdn)$/i.test(path); | |
| 40 | +} | |
| 41 | + | |
| 42 | +function repositoryReferences(repository) { | |
| 43 | + const branches = Array.isArray(repository.branches) && repository.branches.length | |
| 44 | + ? repository.branches | |
| 45 | + : repository.defaultBranch | |
| 46 | + ? [{ name: repository.defaultBranch, sha: repository.headSha, protected: false }] | |
| 47 | + : []; | |
| 48 | + return { | |
| 49 | + branches, | |
| 50 | + tags: Array.isArray(repository.tags) ? repository.tags : [], | |
| 51 | + }; | |
| 52 | +} | |
| 53 | + | |
| 54 | +function resolveRepositoryReference(repository, requestedName = "", requestedType = "") { | |
| 55 | + const references = repositoryReferences(repository); | |
| 56 | + if (!requestedName && !references.branches.length) { | |
| 57 | + return { | |
| 58 | + name: "No branch", | |
| 59 | + type: "branch", | |
| 60 | + sha: repository.headSha || null, | |
| 61 | + isDefault: true, | |
| 62 | + files: repository.files || [], | |
| 63 | + commits: repository.commits || [], | |
| 64 | + }; | |
| 65 | + } | |
| 66 | + | |
| 67 | + let reference; | |
| 68 | + let type; | |
| 69 | + if (!requestedName) { | |
| 70 | + reference = references.branches.find((item) => item.name === repository.defaultBranch) | |
| 71 | + || references.branches[0]; | |
| 72 | + type = "branch"; | |
| 73 | + } else if (requestedType === "tag") { | |
| 74 | + reference = references.tags.find((item) => item.name === requestedName); | |
| 75 | + type = "tag"; | |
| 76 | + } else if (requestedType === "branch") { | |
| 77 | + reference = references.branches.find((item) => item.name === requestedName); | |
| 78 | + type = "branch"; | |
| 79 | + } else { | |
| 80 | + reference = references.branches.find((item) => item.name === requestedName); | |
| 81 | + type = reference ? "branch" : "tag"; | |
| 82 | + if (!reference) reference = references.tags.find((item) => item.name === requestedName); | |
| 83 | + } | |
| 84 | + if (!reference) return undefined; | |
| 85 | + | |
| 86 | + const isDefault = type === "branch" && reference.name === repository.defaultBranch; | |
| 87 | + const stored = isDefault || reference.sha === repository.headSha | |
| 88 | + ? { files: repository.files || [], commits: repository.commits || [] } | |
| 89 | + : repository.refSnapshots?.find((item) => item.sha === reference.sha); | |
| 90 | + if (!stored) return undefined; | |
| 91 | + return { | |
| 92 | + ...reference, | |
| 93 | + type, | |
| 94 | + isDefault, | |
| 95 | + files: stored.files, | |
| 96 | + commits: stored.commits, | |
| 97 | + }; | |
| 98 | +} | |
| 99 | + | |
| 100 | +function repositoryUrl(shareId, repositoryId, reference, params = {}) { | |
| 101 | + const query = new URLSearchParams(); | |
| 102 | + if (reference && !reference.isDefault && reference.name !== "No branch") { | |
| 103 | + query.set("ref", reference.name); | |
| 104 | + query.set("refType", reference.type); | |
| 105 | + } | |
| 106 | + for (const [name, value] of Object.entries(params)) { | |
| 107 | + if (value !== undefined && value !== null && value !== "") query.set(name, value); | |
| 108 | + } | |
| 109 | + const base = `/s/${encodeURIComponent(shareId)}/repositories/${encodeURIComponent(repositoryId)}`; | |
| 110 | + return query.size ? `${base}?${query}` : base; | |
| 111 | +} | |
| 112 | + | |
| 113 | +function searchSnapshot(repositories, query) { | |
| 114 | + const needle = query.toLocaleLowerCase("en"); | |
| 115 | + const repositoryResults = []; | |
| 116 | + const codeResults = []; | |
| 117 | + const commitResults = []; | |
| 118 | + const issueResults = []; | |
| 119 | + const pullRequestResults = []; | |
| 120 | + const releaseResults = []; | |
| 121 | + | |
| 122 | + for (const repository of repositories) { | |
| 123 | + const repositoryText = [ | |
| 124 | + repository.name, | |
| 125 | + repository.fullName, | |
| 126 | + repository.description, | |
| 127 | + repository.language, | |
| 128 | + ].filter(Boolean).join("\n").toLocaleLowerCase("en"); | |
| 129 | + if (repositoryText.includes(needle)) repositoryResults.push(repository); | |
| 130 | + | |
| 131 | + for (const file of repository.files) { | |
| 132 | + const pathMatches = file.path.toLocaleLowerCase("en").includes(needle); | |
| 133 | + const lines = typeof file.content === "string" ? file.content.split(/\r?\n/) : []; | |
| 134 | + const lineIndex = lines.findIndex((line) => line.toLocaleLowerCase("en").includes(needle)); | |
| 135 | + if (!pathMatches && lineIndex === -1) continue; | |
| 136 | + codeResults.push({ | |
| 137 | + repository, | |
| 138 | + file, | |
| 139 | + lineNumber: lineIndex === -1 ? 1 : lineIndex + 1, | |
| 140 | + snippet: lineIndex === -1 ? "" : lines[lineIndex].slice(0, 300), | |
| 141 | + view: isMarkdownFile(file.path) ? "source" : undefined, | |
| 142 | + }); | |
| 143 | + } | |
| 144 | + | |
| 145 | + for (const commit of repository.commits) { | |
| 146 | + const commitText = [ | |
| 147 | + commit.sha, | |
| 148 | + commit.message, | |
| 149 | + commit.author, | |
| 150 | + ].filter(Boolean).join("\n").toLocaleLowerCase("en"); | |
| 151 | + if (commitText.includes(needle)) commitResults.push({ repository, commit }); | |
| 152 | + } | |
| 153 | + | |
| 154 | + for (const issue of repository.issues || []) { | |
| 155 | + const issueText = [ | |
| 156 | + issue.title, | |
| 157 | + issue.body, | |
| 158 | + issue.author?.login, | |
| 159 | + ...(issue.labels || []).map((label) => label.name), | |
| 160 | + ...(issue.comments || []).map((comment) => comment.body), | |
| 161 | + ].filter(Boolean).join("\n").toLocaleLowerCase("en"); | |
| 162 | + if (issueText.includes(needle)) issueResults.push({ repository, issue }); | |
| 163 | + } | |
| 164 | + | |
| 165 | + for (const pullRequest of repository.pullRequests || []) { | |
| 166 | + const pullText = [ | |
| 167 | + pullRequest.title, | |
| 168 | + pullRequest.body, | |
| 169 | + pullRequest.author?.login, | |
| 170 | + pullRequest.head, | |
| 171 | + pullRequest.base, | |
| 172 | + ...(pullRequest.labels || []).map((label) => label.name), | |
| 173 | + ...(pullRequest.conversation || []).map((comment) => comment.body), | |
| 174 | + ...(pullRequest.files || []).map((file) => file.filename), | |
| 175 | + ].filter(Boolean).join("\n").toLocaleLowerCase("en"); | |
| 176 | + if (pullText.includes(needle)) pullRequestResults.push({ repository, pullRequest }); | |
| 177 | + } | |
| 178 | + | |
| 179 | + for (const release of repository.releases || []) { | |
| 180 | + const releaseText = [ | |
| 181 | + release.name, | |
| 182 | + release.tagName, | |
| 183 | + release.body, | |
| 184 | + release.author?.login, | |
| 185 | + ...(release.assets || []).map((asset) => asset.name), | |
| 186 | + ].filter(Boolean).join("\n").toLocaleLowerCase("en"); | |
| 187 | + if (releaseText.includes(needle)) releaseResults.push({ repository, release }); | |
| 188 | + } | |
| 189 | + } | |
| 190 | + | |
| 191 | + return { | |
| 192 | + repositories: repositoryResults.slice(0, 50), | |
| 193 | + code: codeResults.slice(0, 100), | |
| 194 | + commits: commitResults.slice(0, 100), | |
| 195 | + issues: issueResults.slice(0, 100), | |
| 196 | + pulls: pullRequestResults.slice(0, 100), | |
| 197 | + releases: releaseResults.slice(0, 100), | |
| 198 | + }; | |
| 199 | +} | |
| 200 | + | |
| 38 | 201 | export function createApp({ config, store, github, now = () => new Date() }) { |
| 39 | 202 | const app = express(); |
| 40 | 203 | const tokenRefreshes = new Map(); |
| @@ -76,13 +239,23 @@export function createApp({ config, store, github, now = () => new Date() }) { | ||
| 76 | 239 | if (file) { |
| 77 | 240 | return { |
| 78 | 241 | tagName, |
| 79 | - attribs: { ...attributes, href: `${options.baseUrl}?file=${encodeURIComponent(target)}` }, | |
| 242 | + attribs: { | |
| 243 | + ...attributes, | |
| 244 | + href: options.repositoryUrl | |
| 245 | + ? options.repositoryUrl({ file: target }) | |
| 246 | + : `${options.baseUrl}?file=${encodeURIComponent(target)}`, | |
| 247 | + }, | |
| 80 | 248 | }; |
| 81 | 249 | } |
| 82 | 250 | if (folder) { |
| 83 | 251 | return { |
| 84 | 252 | tagName, |
| 85 | - attribs: { ...attributes, href: `${options.baseUrl}?path=${encodeURIComponent(target)}` }, | |
| 253 | + attribs: { | |
| 254 | + ...attributes, | |
| 255 | + href: options.repositoryUrl | |
| 256 | + ? options.repositoryUrl({ path: target }) | |
| 257 | + : `${options.baseUrl}?path=${encodeURIComponent(target)}`, | |
| 258 | + }, | |
| 86 | 259 | }; |
| 87 | 260 | } |
| 88 | 261 | return { tagName, attribs: { ...attributes, href: "#" } }; |
| @@ -187,14 +360,38 @@export function createApp({ config, store, github, now = () => new Date() }) { | ||
| 187 | 360 | |
| 188 | 361 | app.get("/", async (req, res, next) => { |
| 189 | 362 | try { |
| 190 | - const repos = req.owner?.installation_id | |
| 191 | - ? await github.listRepositories(await ownerToken(req), req.owner.installation_id) | |
| 363 | + let repos = []; | |
| 364 | + let repositoryError; | |
| 365 | + if (req.owner?.installation_id) { | |
| 366 | + try { | |
| 367 | + repos = await github.listRepositories(await ownerToken(req), req.owner.installation_id); | |
| 368 | + } catch (error) { | |
| 369 | + repositoryError = error.publicMessage | |
| 370 | + || "GitHub repositories could not be loaded. Existing snapshot links can still be managed below."; | |
| 371 | + } | |
| 372 | + } | |
| 373 | + const currentTime = now(); | |
| 374 | + const shares = req.owner | |
| 375 | + ? store.listSharesForOwner(req.owner.id).map((share) => ({ | |
| 376 | + ...share, | |
| 377 | + url: `${config.baseUrl}/s/${share.id}`, | |
| 378 | + status: share.revoked_at | |
| 379 | + ? "revoked" | |
| 380 | + : new Date(share.expires_at) <= currentTime | |
| 381 | + ? "expired" | |
| 382 | + : "active", | |
| 383 | + repositories: share.summary?.repositories || [], | |
| 384 | + })) | |
| 192 | 385 | : []; |
| 193 | 386 | res.render("dashboard", { |
| 194 | 387 | owner: req.owner, |
| 195 | 388 | repos, |
| 389 | + shares, | |
| 196 | 390 | appConfigured: Boolean(config.github.clientId && config.github.clientSecret && config.github.appSlug), |
| 197 | - error: req.query.error, | |
| 391 | + error: req.query.error || repositoryError, | |
| 392 | + notice: req.query.notice === "revoked" | |
| 393 | + ? "Snapshot access was revoked and its stored repository content was removed." | |
| 394 | + : undefined, | |
| 198 | 395 | }); |
| 199 | 396 | } catch (error) { |
| 200 | 397 | next(error); |
| @@ -223,6 +420,16 @@export function createApp({ config, store, github, now = () => new Date() }) { | ||
| 223 | 420 | } |
| 224 | 421 | }); |
| 225 | 422 | |
| 423 | + app.post("/auth/logout", (req, res) => { | |
| 424 | + if (req.sessionId) store.deleteSession(req.sessionId); | |
| 425 | + res.clearCookie("profileshare_session", { | |
| 426 | + httpOnly: true, | |
| 427 | + sameSite: "lax", | |
| 428 | + secure: config.baseUrl.startsWith("https://"), | |
| 429 | + }); | |
| 430 | + res.redirect("/"); | |
| 431 | + }); | |
| 432 | + | |
| 226 | 433 | app.get("/github/install", (req, res) => { |
| 227 | 434 | if (!req.owner) return res.redirect("/auth/github"); |
| 228 | 435 | const state = randomId(); |
| @@ -292,57 +499,249 @@export function createApp({ config, store, github, now = () => new Date() }) { | ||
| 292 | 499 | } |
| 293 | 500 | }); |
| 294 | 501 | |
| 502 | + app.post("/shares/:shareId/revoke", (req, res) => { | |
| 503 | + if (!req.owner) return res.status(401).render("error", { message: "Sign in to manage snapshot links." }); | |
| 504 | + const share = store.getShare(req.params.shareId); | |
| 505 | + if (!share || share.owner_id !== req.owner.id) { | |
| 506 | + return res.status(404).render("error", { message: "That snapshot link was not found." }); | |
| 507 | + } | |
| 508 | + if (share.revoked_at) return res.redirect("/#shared-links"); | |
| 509 | + const currentTime = now(); | |
| 510 | + if (new Date(share.expires_at) <= currentTime) { | |
| 511 | + if (share.snapshot) store.purgeShareSnapshot(share.id); | |
| 512 | + return res.redirect("/#shared-links"); | |
| 513 | + } | |
| 514 | + store.revokeShare(share.id, req.owner.id, currentTime.toISOString()); | |
| 515 | + return res.redirect("/?notice=revoked#shared-links"); | |
| 516 | + }); | |
| 517 | + | |
| 295 | 518 | function loadShare(req, res, next) { |
| 296 | 519 | const share = store.getShare(req.params.shareId); |
| 297 | 520 | if (!share) return res.status(404).render("error", { message: "This shared URL does not exist." }); |
| 521 | + if (share.revoked_at) { | |
| 522 | + return res.status(410).render("expired", { reason: "revoked" }); | |
| 523 | + } | |
| 298 | 524 | if (new Date(share.expires_at) <= now()) { |
| 299 | 525 | if (share.snapshot) store.purgeShareSnapshot(share.id); |
| 300 | - return res.status(410).render("expired"); | |
| 526 | + return res.status(410).render("expired", { reason: "expired" }); | |
| 301 | 527 | } |
| 302 | 528 | req.share = share; |
| 303 | 529 | next(); |
| 304 | 530 | } |
| 305 | 531 | |
| 306 | 532 | app.get("/s/:shareId", loadShare, (req, res) => { |
| 307 | - const commits = req.share.snapshot.repositories | |
| 533 | + const allRepositories = req.share.snapshot.repositories; | |
| 534 | + const commits = allRepositories | |
| 308 | 535 | .flatMap((repo) => repo.commits.map((commit) => ({ |
| 309 | 536 | ...commit, |
| 310 | 537 | repository: repo.name, |
| 311 | 538 | repositoryId: repo.id, |
| 312 | 539 | }))) |
| 313 | 540 | .sort((a, b) => new Date(b.date) - new Date(a.date)); |
| 314 | - res.render("profile", { share: req.share, commits }); | |
| 541 | + const tab = req.query.tab === "repositories" ? "repositories" : "overview"; | |
| 542 | + const repositoryQuery = String(req.query.q || "").trim().slice(0, 100); | |
| 543 | + const selectedLanguage = String(req.query.language || "").slice(0, 50); | |
| 544 | + const sort = ["name", "commits"].includes(req.query.sort) ? req.query.sort : "updated"; | |
| 545 | + const languages = [...new Set(allRepositories.map((repo) => repo.language).filter(Boolean))] | |
| 546 | + .sort((a, b) => a.localeCompare(b)); | |
| 547 | + const queryNeedle = repositoryQuery.toLocaleLowerCase("en"); | |
| 548 | + const repositories = allRepositories | |
| 549 | + .filter((repo) => !queryNeedle || [ | |
| 550 | + repo.name, | |
| 551 | + repo.description, | |
| 552 | + repo.language, | |
| 553 | + ].filter(Boolean).join("\n").toLocaleLowerCase("en").includes(queryNeedle)) | |
| 554 | + .filter((repo) => !selectedLanguage || repo.language === selectedLanguage) | |
| 555 | + .sort((a, b) => { | |
| 556 | + if (sort === "name") return a.name.localeCompare(b.name); | |
| 557 | + if (sort === "commits") return b.commits.length - a.commits.length || a.name.localeCompare(b.name); | |
| 558 | + return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0) || a.name.localeCompare(b.name); | |
| 559 | + }); | |
| 560 | + res.render("profile", { | |
| 561 | + share: req.share, | |
| 562 | + commits, | |
| 563 | + tab, | |
| 564 | + repositories, | |
| 565 | + repositoryQuery, | |
| 566 | + selectedLanguage, | |
| 567 | + sort, | |
| 568 | + languages, | |
| 569 | + }); | |
| 570 | + }); | |
| 571 | + | |
| 572 | + app.get("/s/:shareId/search", loadShare, (req, res) => { | |
| 573 | + const query = String(req.query.q || "").trim().slice(0, 100); | |
| 574 | + const repositoryId = String(req.query.repository || ""); | |
| 575 | + const referenceName = String(req.query.ref || ""); | |
| 576 | + const referenceType = String(req.query.refType || ""); | |
| 577 | + const type = ["code", "commits", "issues", "pulls", "releases", "repositories"].includes(req.query.type) | |
| 578 | + ? req.query.type | |
| 579 | + : "all"; | |
| 580 | + let repositories = req.share.snapshot.repositories; | |
| 581 | + let repository; | |
| 582 | + let selectedReference; | |
| 583 | + if (referenceName && !repositoryId) { | |
| 584 | + return res.status(400).render("error", { message: "Choose a repository before searching a branch or tag." }); | |
| 585 | + } | |
| 586 | + if (repositoryId) { | |
| 587 | + repository = repositories.find((item) => String(item.id) === repositoryId); | |
| 588 | + if (!repository) return res.status(404).render("error", { message: "This repository is not part of the snapshot." }); | |
| 589 | + selectedReference = resolveRepositoryReference(repository, referenceName, referenceType); | |
| 590 | + if (!selectedReference) { | |
| 591 | + return res.status(404).render("error", { message: "This branch or tag is not part of the snapshot." }); | |
| 592 | + } | |
| 593 | + repositories = [{ | |
| 594 | + ...repository, | |
| 595 | + files: selectedReference.files, | |
| 596 | + commits: selectedReference.commits, | |
| 597 | + }]; | |
| 598 | + } | |
| 599 | + const results = query | |
| 600 | + ? searchSnapshot(repositories, query) | |
| 601 | + : { repositories: [], code: [], commits: [], issues: [], pulls: [], releases: [] }; | |
| 602 | + res.render("search", { | |
| 603 | + share: req.share, | |
| 604 | + query, | |
| 605 | + repository, | |
| 606 | + selectedReference, | |
| 607 | + type, | |
| 608 | + results, | |
| 609 | + total: results.repositories.length | |
| 610 | + + results.code.length | |
| 611 | + + results.commits.length | |
| 612 | + + results.issues.length | |
| 613 | + + results.pulls.length | |
| 614 | + + results.releases.length, | |
| 615 | + resultHref: (repositoryId, params) => repositoryUrl( | |
| 616 | + req.share.id, | |
| 617 | + repositoryId, | |
| 618 | + repository && String(repository.id) === String(repositoryId) ? selectedReference : undefined, | |
| 619 | + params, | |
| 620 | + ), | |
| 621 | + globalResultHref: (repositoryId, params) => repositoryUrl( | |
| 622 | + req.share.id, | |
| 623 | + repositoryId, | |
| 624 | + undefined, | |
| 625 | + params, | |
| 626 | + ), | |
| 627 | + }); | |
| 315 | 628 | }); |
| 316 | 629 | |
| 317 | 630 | app.get("/s/:shareId/repositories/:repoId", loadShare, (req, res) => { |
| 318 | - const repository = req.share.snapshot.repositories.find((repo) => String(repo.id) === req.params.repoId); | |
| 319 | - if (!repository) return res.status(404).render("error", { message: "This repository is not part of the snapshot." }); | |
| 631 | + const storedRepository = req.share.snapshot.repositories.find((repo) => String(repo.id) === req.params.repoId); | |
| 632 | + if (!storedRepository) return res.status(404).render("error", { message: "This repository is not part of the snapshot." }); | |
| 633 | + const referenceName = String(req.query.ref || ""); | |
| 634 | + const referenceType = String(req.query.refType || ""); | |
| 635 | + const selectedReference = resolveRepositoryReference(storedRepository, referenceName, referenceType); | |
| 636 | + if (!selectedReference) { | |
| 637 | + return res.status(404).render("error", { message: "This branch or tag is not part of the snapshot." }); | |
| 638 | + } | |
| 639 | + const repository = { | |
| 640 | + ...storedRepository, | |
| 641 | + headSha: selectedReference.sha, | |
| 642 | + files: selectedReference.files, | |
| 643 | + commits: selectedReference.commits, | |
| 644 | + issues: storedRepository.issues || [], | |
| 645 | + pullRequests: storedRepository.pullRequests || [], | |
| 646 | + releases: storedRepository.releases || [], | |
| 647 | + languages: storedRepository.languages || [], | |
| 648 | + }; | |
| 649 | + const repoHref = (params = {}) => repositoryUrl(req.share.id, repository.id, selectedReference, params); | |
| 650 | + const globalRepoHref = (params = {}) => repositoryUrl(req.share.id, repository.id, undefined, params); | |
| 651 | + const refHref = (reference) => repositoryUrl(req.share.id, repository.id, { | |
| 652 | + ...reference, | |
| 653 | + isDefault: reference.type === "branch" && reference.name === storedRepository.defaultBranch, | |
| 654 | + }); | |
| 320 | 655 | const path = String(req.query.path || "").replace(/^\/+|\/+$/g, ""); |
| 321 | 656 | const filePath = req.query.file ? String(req.query.file) : ""; |
| 657 | + const historyPath = req.query.history ? String(req.query.history) : ""; | |
| 322 | 658 | const commitSha = req.query.commit ? String(req.query.commit) : ""; |
| 323 | - const tab = req.query.tab === "commits" ? "commits" : "code"; | |
| 659 | + const issueNumber = req.query.issue ? String(req.query.issue) : ""; | |
| 660 | + const pullNumber = req.query.pull ? String(req.query.pull) : ""; | |
| 661 | + const releaseId = req.query.release ? String(req.query.release) : ""; | |
| 662 | + const requestedTab = String(req.query.tab || ""); | |
| 663 | + const tab = issueNumber | |
| 664 | + ? "issues" | |
| 665 | + : pullNumber | |
| 666 | + ? "pulls" | |
| 667 | + : releaseId | |
| 668 | + ? "releases" | |
| 669 | + : ["commits", "issues", "pulls", "releases"].includes(requestedTab) | |
| 670 | + ? requestedTab | |
| 671 | + : "code"; | |
| 324 | 672 | const file = filePath ? repository.files.find((item) => item.path === filePath) : undefined; |
| 673 | + const markdownFile = Boolean(file && isMarkdownFile(file.path)); | |
| 674 | + const fileView = markdownFile && req.query.view !== "source" | |
| 675 | + ? "preview" | |
| 676 | + : "source"; | |
| 677 | + const historyFile = historyPath ? repository.files.find((item) => item.path === historyPath) : undefined; | |
| 325 | 678 | const commit = commitSha ? repository.commits.find((item) => item.sha === commitSha) : undefined; |
| 326 | - const readme = !path && !file && !commit | |
| 679 | + const issue = issueNumber | |
| 680 | + ? repository.issues.find((item) => String(item.number) === issueNumber) | |
| 681 | + : undefined; | |
| 682 | + const pullRequest = pullNumber | |
| 683 | + ? repository.pullRequests.find((item) => String(item.number) === pullNumber) | |
| 684 | + : undefined; | |
| 685 | + const release = releaseId | |
| 686 | + ? repository.releases.find((item) => String(item.id) === releaseId) | |
| 687 | + : undefined; | |
| 688 | + const stateFilter = ["open", "closed"].includes(req.query.state) ? req.query.state : "all"; | |
| 689 | + const visibleIssues = repository.issues.filter((item) => stateFilter === "all" || item.state === stateFilter); | |
| 690 | + const visiblePullRequests = repository.pullRequests.filter( | |
| 691 | + (item) => stateFilter === "all" || item.state === stateFilter, | |
| 692 | + ); | |
| 693 | + const fileHistory = historyFile | |
| 694 | + ? repository.commits | |
| 695 | + .map((item) => ({ | |
| 696 | + ...item, | |
| 697 | + fileChange: item.files.find((changed) => changed.filename === historyFile.path), | |
| 698 | + })) | |
| 699 | + .filter((item) => item.fileChange) | |
| 700 | + : []; | |
| 701 | + const readme = !path && !file && !historyFile && !commit | |
| 327 | 702 | ? repository.files.find((item) => /^readme(?:\.[^/]+)?$/i.test(item.path) && item.content) |
| 328 | 703 | : undefined; |
| 329 | - if ((filePath && !file) || (commitSha && !commit)) return res.status(404).render("error", { message: "That item is not part of the snapshot." }); | |
| 704 | + if ( | |
| 705 | + (filePath && !file) | |
| 706 | + || (historyPath && !historyFile) | |
| 707 | + || (commitSha && !commit) | |
| 708 | + || (issueNumber && !issue) | |
| 709 | + || (pullNumber && !pullRequest) | |
| 710 | + || (releaseId && !release) | |
| 711 | + ) { | |
| 712 | + return res.status(404).render("error", { message: "That item is not part of the snapshot." }); | |
| 713 | + } | |
| 330 | 714 | res.render("repository", { |
| 331 | 715 | share: req.share, |
| 332 | 716 | repository, |
| 333 | 717 | path, |
| 334 | 718 | browser: foldersFor(repository.files, path), |
| 335 | 719 | file, |
| 720 | + markdownFile, | |
| 721 | + fileView, | |
| 722 | + historyFile, | |
| 723 | + fileHistory, | |
| 336 | 724 | commit, |
| 725 | + issue, | |
| 726 | + pullRequest, | |
| 727 | + release, | |
| 728 | + stateFilter, | |
| 729 | + visibleIssues, | |
| 730 | + visiblePullRequests, | |
| 337 | 731 | readme, |
| 338 | 732 | tab, |
| 733 | + selectedReference, | |
| 734 | + references: repositoryReferences(storedRepository), | |
| 735 | + repoHref, | |
| 736 | + globalRepoHref, | |
| 737 | + refHref, | |
| 339 | 738 | }); |
| 340 | 739 | }); |
| 341 | 740 | |
| 342 | 741 | app.use((error, req, res, next) => { |
| 343 | 742 | console.error(error); |
| 344 | 743 | if (res.headersSent) return next(error); |
| 345 | - res.status(500).render("error", { message: "The request could not be completed." }); | |
| 744 | + res.status(500).render("error", { message: error.publicMessage || "The request could not be completed." }); | |
| 346 | 745 | }); |
| 347 | 746 | |
| 348 | 747 | return app; |
modified src/db.js +59 −4
| @@ -46,7 +46,9 @@export function createStore(path = ":memory:", secret = "test-only-secret") { | ||
| 46 | 46 | owner_id INTEGER NOT NULL, |
| 47 | 47 | created_at TEXT NOT NULL, |
| 48 | 48 | expires_at TEXT NOT NULL, |
| 49 | - snapshot TEXT NOT NULL | |
| 49 | + snapshot TEXT NOT NULL, | |
| 50 | + summary TEXT, | |
| 51 | + revoked_at TEXT | |
| 50 | 52 | ); |
| 51 | 53 | `); |
| 52 | 54 | const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all().map((column) => column.name); |
| @@ -60,6 +62,13 @@export function createStore(path = ":memory:", secret = "test-only-secret") { | ||
| 60 | 62 | if (!ownerColumns.includes("token_expires_at")) { |
| 61 | 63 | db.exec("ALTER TABLE owners ADD COLUMN token_expires_at TEXT"); |
| 62 | 64 | } |
| 65 | + const shareColumns = db.prepare("PRAGMA table_info(shares)").all().map((column) => column.name); | |
| 66 | + if (!shareColumns.includes("revoked_at")) { | |
| 67 | + db.exec("ALTER TABLE shares ADD COLUMN revoked_at TEXT"); | |
| 68 | + } | |
| 69 | + if (!shareColumns.includes("summary")) { | |
| 70 | + db.exec("ALTER TABLE shares ADD COLUMN summary TEXT"); | |
| 71 | + } | |
| 63 | 72 | |
| 64 | 73 | return { |
| 65 | 74 | createSession(id, now = new Date()) { |
| @@ -76,6 +85,9 @@export function createStore(path = ":memory:", secret = "test-only-secret") { | ||
| 76 | 85 | getSession(id) { |
| 77 | 86 | return db.prepare("SELECT * FROM sessions WHERE id = ?").get(id); |
| 78 | 87 | }, |
| 88 | + deleteSession(id) { | |
| 89 | + db.prepare("DELETE FROM sessions WHERE id = ?").run(id); | |
| 90 | + }, | |
| 79 | 91 | setSessionState(id, state) { |
| 80 | 92 | db.prepare("UPDATE sessions SET oauth_state = ? WHERE id = ?").run(state, id); |
| 81 | 93 | }, |
| @@ -141,15 +153,58 @@export function createStore(path = ":memory:", secret = "test-only-secret") { | ||
| 141 | 153 | db.prepare("UPDATE owners SET installation_id = ? WHERE id = ?").run(installationId, ownerId); |
| 142 | 154 | }, |
| 143 | 155 | createShare(share) { |
| 156 | + const summary = { | |
| 157 | + repositories: (share.snapshot?.repositories || []).map((repository) => ({ name: repository.name })), | |
| 158 | + }; | |
| 144 | 159 | db.prepare(` |
| 145 | - INSERT INTO shares (id, owner_id, created_at, expires_at, snapshot) | |
| 146 | - VALUES (?, ?, ?, ?, ?) | |
| 147 | - `).run(share.id, share.ownerId, share.createdAt, share.expiresAt, JSON.stringify(share.snapshot)); | |
| 160 | + INSERT INTO shares (id, owner_id, created_at, expires_at, snapshot, summary) | |
| 161 | + VALUES (?, ?, ?, ?, ?, ?) | |
| 162 | + `).run( | |
| 163 | + share.id, | |
| 164 | + share.ownerId, | |
| 165 | + share.createdAt, | |
| 166 | + share.expiresAt, | |
| 167 | + JSON.stringify(share.snapshot), | |
| 168 | + JSON.stringify(summary), | |
| 169 | + ); | |
| 148 | 170 | }, |
| 149 | 171 | getShare(id) { |
| 150 | 172 | const row = db.prepare("SELECT * FROM shares WHERE id = ?").get(id); |
| 151 | 173 | return row ? { ...row, snapshot: JSON.parse(row.snapshot) } : undefined; |
| 152 | 174 | }, |
| 175 | + listSharesForOwner(ownerId) { | |
| 176 | + return db.prepare(` | |
| 177 | + SELECT id, owner_id, created_at, expires_at, revoked_at, summary, | |
| 178 | + CASE WHEN summary IS NULL THEN snapshot ELSE NULL END AS legacy_snapshot | |
| 179 | + FROM shares | |
| 180 | + WHERE owner_id = ? | |
| 181 | + ORDER BY created_at DESC | |
| 182 | + `).all(ownerId).map((row) => { | |
| 183 | + const legacySnapshot = row.legacy_snapshot ? JSON.parse(row.legacy_snapshot) : null; | |
| 184 | + return { | |
| 185 | + id: row.id, | |
| 186 | + owner_id: row.owner_id, | |
| 187 | + created_at: row.created_at, | |
| 188 | + expires_at: row.expires_at, | |
| 189 | + revoked_at: row.revoked_at, | |
| 190 | + summary: row.summary | |
| 191 | + ? JSON.parse(row.summary) | |
| 192 | + : { | |
| 193 | + repositories: (legacySnapshot?.repositories || []).map((repository) => ({ | |
| 194 | + name: repository.name, | |
| 195 | + })), | |
| 196 | + }, | |
| 197 | + }; | |
| 198 | + }); | |
| 199 | + }, | |
| 200 | + revokeShare(id, ownerId, revokedAt) { | |
| 201 | + const result = db.prepare(` | |
| 202 | + UPDATE shares | |
| 203 | + SET snapshot = 'null', summary = NULL, revoked_at = ? | |
| 204 | + WHERE id = ? AND owner_id = ? AND revoked_at IS NULL | |
| 205 | + `).run(revokedAt, id, ownerId); | |
| 206 | + return result.changes > 0; | |
| 207 | + }, | |
| 153 | 208 | purgeShareSnapshot(id) { |
| 154 | 209 | db.prepare("UPDATE shares SET snapshot = 'null' WHERE id = ?").run(id); |
| 155 | 210 | }, |
modified src/github.js +263 −26
| @@ -20,7 +20,14 @@async function request(path, token, options = {}) { | ||
| 20 | 20 | if (!response.ok) { |
| 21 | 21 | const detail = await response.text(); |
| 22 | 22 | if (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0") { |
| 23 | - throw new Error("GitHub API rate limit reached. Try creating the snapshot after the reset time."); | |
| 23 | + const error = new Error("GitHub API rate limit reached. Try creating the snapshot after the reset time."); | |
| 24 | + error.publicMessage = error.message; | |
| 25 | + throw error; | |
| 26 | + } | |
| 27 | + if (response.status === 403) { | |
| 28 | + const error = new Error(`GitHub request failed (${response.status}): ${detail}`); | |
| 29 | + error.publicMessage = "GitHub denied access. Grant the GitHub App read-only Contents, Issues, Metadata, and Pull requests permissions, approve the updated installation, then try again."; | |
| 30 | + throw error; | |
| 24 | 31 | } |
| 25 | 32 | throw new Error(`GitHub request failed (${response.status}): ${detail}`); |
| 26 | 33 | } |
| @@ -96,6 +103,38 @@function decodeFile(content) { | ||
| 96 | 103 | return buffer.toString("utf8"); |
| 97 | 104 | } |
| 98 | 105 | |
| 106 | +function imageMediaType(path) { | |
| 107 | + const extension = path.toLocaleLowerCase("en").split(".").pop(); | |
| 108 | + return { | |
| 109 | + png: "image/png", | |
| 110 | + jpg: "image/jpeg", | |
| 111 | + jpeg: "image/jpeg", | |
| 112 | + gif: "image/gif", | |
| 113 | + webp: "image/webp", | |
| 114 | + }[extension] || null; | |
| 115 | +} | |
| 116 | + | |
| 117 | +function account(user) { | |
| 118 | + return { | |
| 119 | + login: user?.login || "ghost", | |
| 120 | + avatarUrl: user?.avatar_url || "", | |
| 121 | + }; | |
| 122 | +} | |
| 123 | + | |
| 124 | +function conversationComment(comment, type = "comment") { | |
| 125 | + return { | |
| 126 | + id: comment.id, | |
| 127 | + type, | |
| 128 | + body: comment.body || "", | |
| 129 | + author: account(comment.user), | |
| 130 | + createdAt: comment.created_at || comment.submitted_at, | |
| 131 | + updatedAt: comment.updated_at || comment.submitted_at, | |
| 132 | + state: comment.state || null, | |
| 133 | + path: comment.path || null, | |
| 134 | + line: comment.line || comment.original_line || null, | |
| 135 | + }; | |
| 136 | +} | |
| 137 | + | |
| 99 | 138 | export function createGitHubClient(config) { |
| 100 | 139 | async function requestUserToken(body) { |
| 101 | 140 | let response; |
| @@ -177,60 +216,258 @@export function createGitHubClient(config) { | ||
| 177 | 216 | name: repo.name, |
| 178 | 217 | fullName: repo.full_name, |
| 179 | 218 | description: repo.description, |
| 219 | + private: repo.private, | |
| 180 | 220 | language: repo.language, |
| 221 | + updatedAt: repo.updated_at, | |
| 222 | + createdAt: repo.created_at, | |
| 223 | + homepage: repo.homepage || null, | |
| 224 | + topics: repo.topics || [], | |
| 225 | + license: repo.license ? { name: repo.license.name, spdxId: repo.license.spdx_id } : null, | |
| 226 | + stargazersCount: repo.stargazers_count || 0, | |
| 227 | + forksCount: repo.forks_count || 0, | |
| 228 | + watchersCount: repo.subscribers_count || repo.watchers_count || 0, | |
| 229 | + archived: Boolean(repo.archived), | |
| 181 | 230 | defaultBranch: null, |
| 182 | 231 | headSha: null, |
| 232 | + branches: [], | |
| 233 | + tags: [], | |
| 234 | + refSnapshots: [], | |
| 235 | + issues: [], | |
| 236 | + pullRequests: [], | |
| 237 | + releases: [], | |
| 238 | + languages: [], | |
| 183 | 239 | files: [], |
| 184 | 240 | commits: [], |
| 185 | 241 | }; |
| 186 | 242 | } |
| 187 | - const head = await request(`${root}/commits/${encodeURIComponent(repo.default_branch)}`, token); | |
| 188 | - const [tree, commits] = await Promise.all([ | |
| 189 | - completeTree(root, head.commit.tree.sha, token), | |
| 190 | - allPages(`${root}/commits?sha=${encodeURIComponent(head.sha)}`, token), | |
| 243 | + const [ | |
| 244 | + head, | |
| 245 | + branchRows, | |
| 246 | + tagRows, | |
| 247 | + issueRows, | |
| 248 | + pullRows, | |
| 249 | + releaseRows, | |
| 250 | + languageBytes, | |
| 251 | + ] = await Promise.all([ | |
| 252 | + request(`${root}/commits/${encodeURIComponent(repo.default_branch)}`, token), | |
| 253 | + allPages(`${root}/branches`, token), | |
| 254 | + allPages(`${root}/tags`, token), | |
| 255 | + allPages(`${root}/issues?state=all&sort=updated&direction=desc`, token), | |
| 256 | + allPages(`${root}/pulls?state=all&sort=updated&direction=desc`, token), | |
| 257 | + allPages(`${root}/releases`, token), | |
| 258 | + request(`${root}/languages`, token), | |
| 191 | 259 | ]); |
| 192 | - const files = await mapLimit( | |
| 193 | - tree.filter((entry) => entry.type === "blob"), | |
| 194 | - 8, | |
| 195 | - async (entry) => { | |
| 196 | - if (entry.size > 500_000) return { path: entry.path, size: entry.size, content: null, truncated: true }; | |
| 197 | - const blob = await request(`${root}/git/blobs/${entry.sha}`, token); | |
| 260 | + | |
| 261 | + const branches = branchRows.map((branch) => ({ | |
| 262 | + name: branch.name, | |
| 263 | + sha: branch.commit.sha, | |
| 264 | + protected: Boolean(branch.protected), | |
| 265 | + })); | |
| 266 | + const defaultBranch = branches.find((branch) => branch.name === repo.default_branch); | |
| 267 | + if (defaultBranch) defaultBranch.sha = head.sha; | |
| 268 | + else branches.unshift({ name: repo.default_branch, sha: head.sha, protected: false }); | |
| 269 | + const tags = tagRows.map((tag) => ({ name: tag.name, sha: tag.commit.sha })); | |
| 270 | + const referenceShas = [...new Set([ | |
| 271 | + head.sha, | |
| 272 | + ...branches.map((branch) => branch.sha), | |
| 273 | + ...tags.map((tag) => tag.sha), | |
| 274 | + ])]; | |
| 275 | + const heads = await mapLimit(referenceShas, 4, (sha) => ( | |
| 276 | + sha === head.sha | |
| 277 | + ? head | |
| 278 | + : request(`${root}/commits/${encodeURIComponent(sha)}`, token) | |
| 279 | + )); | |
| 280 | + | |
| 281 | + const blobCache = new Map(); | |
| 282 | + async function snapshotFile(entry) { | |
| 283 | + if (entry.size > 500_000) { | |
| 284 | + return { path: entry.path, size: entry.size, content: null, truncated: true }; | |
| 285 | + } | |
| 286 | + let cached = blobCache.get(entry.sha); | |
| 287 | + if (!cached) { | |
| 288 | + cached = request(`${root}/git/blobs/${entry.sha}`, token); | |
| 289 | + blobCache.set(entry.sha, cached); | |
| 290 | + } | |
| 291 | + const blob = await cached; | |
| 292 | + const mediaType = imageMediaType(entry.path); | |
| 293 | + return { | |
| 294 | + path: entry.path, | |
| 295 | + size: entry.size, | |
| 296 | + content: !mediaType && blob.encoding === "base64" ? decodeFile(blob.content) : null, | |
| 297 | + binaryContent: mediaType && blob.encoding === "base64" | |
| 298 | + ? blob.content.replace(/\n/g, "") | |
| 299 | + : null, | |
| 300 | + mediaType, | |
| 301 | + truncated: false, | |
| 302 | + }; | |
| 303 | + } | |
| 304 | + | |
| 305 | + const commitCache = new Map(); | |
| 306 | + async function snapshotCommit(commit) { | |
| 307 | + let cached = commitCache.get(commit.sha); | |
| 308 | + if (!cached) { | |
| 309 | + cached = commitWithAllFiles(root, commit.sha, token).then((detail) => { | |
| 310 | + const metadata = commit.commit || detail.commit; | |
| 311 | + return { | |
| 312 | + sha: commit.sha, | |
| 313 | + message: metadata.message, | |
| 314 | + author: metadata.author.name, | |
| 315 | + date: metadata.author.date, | |
| 316 | + additions: detail.stats?.additions || 0, | |
| 317 | + deletions: detail.stats?.deletions || 0, | |
| 318 | + files: (detail.files || []).map((file) => ({ | |
| 319 | + filename: file.filename, | |
| 320 | + status: file.status, | |
| 321 | + additions: file.additions, | |
| 322 | + deletions: file.deletions, | |
| 323 | + patch: file.patch || "", | |
| 324 | + })), | |
| 325 | + }; | |
| 326 | + }); | |
| 327 | + commitCache.set(commit.sha, cached); | |
| 328 | + } | |
| 329 | + return cached; | |
| 330 | + } | |
| 331 | + | |
| 332 | + const refSnapshots = await mapLimit(heads, 2, async (refHead) => { | |
| 333 | + const [tree, commits] = await Promise.all([ | |
| 334 | + completeTree(root, refHead.commit.tree.sha, token), | |
| 335 | + allPages(`${root}/commits?sha=${encodeURIComponent(refHead.sha)}`, token), | |
| 336 | + ]); | |
| 337 | + return { | |
| 338 | + sha: refHead.sha, | |
| 339 | + files: await mapLimit(tree.filter((entry) => entry.type === "blob"), 8, snapshotFile), | |
| 340 | + commits: await mapLimit(commits, 4, snapshotCommit), | |
| 341 | + }; | |
| 342 | + }); | |
| 343 | + const issues = await mapLimit( | |
| 344 | + issueRows.filter((issue) => !issue.pull_request), | |
| 345 | + 4, | |
| 346 | + async (issue) => { | |
| 347 | + const comments = await allPages(`${root}/issues/${issue.number}/comments`, token); | |
| 198 | 348 | return { |
| 199 | - path: entry.path, | |
| 200 | - size: entry.size, | |
| 201 | - content: blob.encoding === "base64" ? decodeFile(blob.content) : null, | |
| 202 | - truncated: false, | |
| 349 | + number: issue.number, | |
| 350 | + title: issue.title, | |
| 351 | + body: issue.body || "", | |
| 352 | + state: issue.state, | |
| 353 | + stateReason: issue.state_reason || null, | |
| 354 | + locked: Boolean(issue.locked), | |
| 355 | + author: account(issue.user), | |
| 356 | + labels: (issue.labels || []).map((label) => ({ | |
| 357 | + name: typeof label === "string" ? label : label.name, | |
| 358 | + color: typeof label === "string" ? null : label.color, | |
| 359 | + })), | |
| 360 | + createdAt: issue.created_at, | |
| 361 | + updatedAt: issue.updated_at, | |
| 362 | + closedAt: issue.closed_at, | |
| 363 | + comments: comments.map((comment) => conversationComment(comment)), | |
| 203 | 364 | }; |
| 204 | 365 | }, |
| 205 | 366 | ); |
| 206 | - const commitDetails = await mapLimit(commits, 4, async (commit) => { | |
| 207 | - const detail = await commitWithAllFiles(root, commit.sha, token); | |
| 367 | + const pullRequests = await mapLimit(pullRows, 3, async (pull) => { | |
| 368 | + const [detail, comments, reviews, reviewComments, files] = await Promise.all([ | |
| 369 | + request(`${root}/pulls/${pull.number}`, token), | |
| 370 | + allPages(`${root}/issues/${pull.number}/comments`, token), | |
| 371 | + allPages(`${root}/pulls/${pull.number}/reviews`, token), | |
| 372 | + allPages(`${root}/pulls/${pull.number}/comments`, token), | |
| 373 | + allPages(`${root}/pulls/${pull.number}/files`, token), | |
| 374 | + ]); | |
| 375 | + const conversation = [ | |
| 376 | + ...comments.map((comment) => conversationComment(comment)), | |
| 377 | + ...reviews.map((review) => conversationComment(review, "review")), | |
| 378 | + ...reviewComments.map((comment) => conversationComment(comment, "review-comment")), | |
| 379 | + ].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); | |
| 208 | 380 | return { |
| 209 | - sha: commit.sha, | |
| 210 | - message: commit.commit.message, | |
| 211 | - author: commit.commit.author.name, | |
| 212 | - date: commit.commit.author.date, | |
| 213 | - additions: detail.stats?.additions || 0, | |
| 214 | - deletions: detail.stats?.deletions || 0, | |
| 215 | - files: (detail.files || []).map((file) => ({ | |
| 381 | + number: detail.number, | |
| 382 | + title: detail.title, | |
| 383 | + body: detail.body || "", | |
| 384 | + state: detail.state, | |
| 385 | + draft: Boolean(detail.draft), | |
| 386 | + merged: Boolean(detail.merged_at), | |
| 387 | + mergeableState: detail.mergeable_state || null, | |
| 388 | + author: account(detail.user), | |
| 389 | + labels: (detail.labels || []).map((label) => ({ | |
| 390 | + name: typeof label === "string" ? label : label.name, | |
| 391 | + color: typeof label === "string" ? null : label.color, | |
| 392 | + })), | |
| 393 | + base: detail.base.ref, | |
| 394 | + head: detail.head.ref, | |
| 395 | + createdAt: detail.created_at, | |
| 396 | + updatedAt: detail.updated_at, | |
| 397 | + closedAt: detail.closed_at, | |
| 398 | + mergedAt: detail.merged_at, | |
| 399 | + additions: detail.additions || 0, | |
| 400 | + deletions: detail.deletions || 0, | |
| 401 | + changedFiles: detail.changed_files || files.length, | |
| 402 | + commitCount: detail.commits || 0, | |
| 403 | + conversation, | |
| 404 | + files: files.map((file) => ({ | |
| 216 | 405 | filename: file.filename, |
| 406 | + previousFilename: file.previous_filename || null, | |
| 217 | 407 | status: file.status, |
| 218 | 408 | additions: file.additions, |
| 219 | 409 | deletions: file.deletions, |
| 410 | + changes: file.changes, | |
| 220 | 411 | patch: file.patch || "", |
| 221 | 412 | })), |
| 222 | 413 | }; |
| 223 | 414 | }); |
| 415 | + const releases = releaseRows.map((release) => ({ | |
| 416 | + id: release.id, | |
| 417 | + tagName: release.tag_name, | |
| 418 | + targetCommitish: release.target_commitish, | |
| 419 | + name: release.name || release.tag_name, | |
| 420 | + body: release.body || "", | |
| 421 | + draft: Boolean(release.draft), | |
| 422 | + prerelease: Boolean(release.prerelease), | |
| 423 | + author: account(release.author), | |
| 424 | + createdAt: release.created_at, | |
| 425 | + publishedAt: release.published_at, | |
| 426 | + assets: (release.assets || []).map((asset) => ({ | |
| 427 | + id: asset.id, | |
| 428 | + name: asset.name, | |
| 429 | + label: asset.label || null, | |
| 430 | + size: asset.size, | |
| 431 | + downloadCount: asset.download_count, | |
| 432 | + contentType: asset.content_type, | |
| 433 | + })), | |
| 434 | + })); | |
| 435 | + const totalLanguageBytes = Object.values(languageBytes).reduce((total, bytes) => total + bytes, 0); | |
| 436 | + const languages = Object.entries(languageBytes) | |
| 437 | + .sort(([, left], [, right]) => right - left) | |
| 438 | + .map(([name, bytes]) => ({ | |
| 439 | + name, | |
| 440 | + bytes, | |
| 441 | + percent: totalLanguageBytes ? Number(((bytes / totalLanguageBytes) * 100).toFixed(1)) : 0, | |
| 442 | + })); | |
| 443 | + const defaultSnapshot = refSnapshots.find((snapshot) => snapshot.sha === head.sha); | |
| 224 | 444 | return { |
| 225 | 445 | id: repo.id, |
| 226 | 446 | name: repo.name, |
| 227 | 447 | fullName: repo.full_name, |
| 228 | 448 | description: repo.description, |
| 449 | + private: repo.private, | |
| 229 | 450 | language: repo.language, |
| 451 | + updatedAt: repo.updated_at, | |
| 452 | + createdAt: repo.created_at, | |
| 453 | + homepage: repo.homepage || null, | |
| 454 | + topics: repo.topics || [], | |
| 455 | + license: repo.license ? { name: repo.license.name, spdxId: repo.license.spdx_id } : null, | |
| 456 | + stargazersCount: repo.stargazers_count || 0, | |
| 457 | + forksCount: repo.forks_count || 0, | |
| 458 | + watchersCount: repo.subscribers_count || repo.watchers_count || 0, | |
| 459 | + archived: Boolean(repo.archived), | |
| 230 | 460 | defaultBranch: repo.default_branch, |
| 231 | 461 | headSha: head.sha, |
| 232 | - files, | |
| 233 | - commits: commitDetails, | |
| 462 | + branches, | |
| 463 | + tags, | |
| 464 | + refSnapshots: refSnapshots.filter((snapshot) => snapshot.sha !== head.sha), | |
| 465 | + issues, | |
| 466 | + pullRequests, | |
| 467 | + releases, | |
| 468 | + languages, | |
| 469 | + files: defaultSnapshot.files, | |
| 470 | + commits: defaultSnapshot.commits, | |
| 234 | 471 | }; |
| 235 | 472 | }); |
| 236 | 473 | }, |
modified src/public/app.js +61 −3
| @@ -1,9 +1,61 @@ | ||
| 1 | 1 | const copyButton = document.querySelector("#copy-link"); |
| 2 | 2 | |
| 3 | -copyButton?.addEventListener("click", async () => { | |
| 3 | +async function copyText(button, value) { | |
| 4 | + const originalLabel = button.textContent; | |
| 5 | + try { | |
| 6 | + await navigator.clipboard.writeText(value); | |
| 7 | + button.textContent = "Copied"; | |
| 8 | + } catch { | |
| 9 | + button.textContent = "Copy failed"; | |
| 10 | + } | |
| 11 | + window.setTimeout(() => { | |
| 12 | + button.textContent = originalLabel; | |
| 13 | + }, 1800); | |
| 14 | +} | |
| 15 | + | |
| 16 | +copyButton?.addEventListener("click", () => { | |
| 4 | 17 | const shareUrl = document.querySelector("#share-url"); |
| 5 | - await navigator.clipboard.writeText(shareUrl.value); | |
| 6 | - copyButton.textContent = "Copied"; | |
| 18 | + copyText(copyButton, shareUrl.value); | |
| 19 | +}); | |
| 20 | + | |
| 21 | +document.querySelectorAll("[data-copy-text]").forEach((button) => { | |
| 22 | + button.addEventListener("click", () => copyText(button, button.dataset.copyText)); | |
| 23 | +}); | |
| 24 | + | |
| 25 | +document.addEventListener("keydown", (event) => { | |
| 26 | + if (event.key !== "/" || event.ctrlKey || event.metaKey || event.altKey) return; | |
| 27 | + if (event.target.closest("input, textarea, select, [contenteditable]")) return; | |
| 28 | + const searchInput = document.querySelector("[data-search-input]"); | |
| 29 | + if (!searchInput) return; | |
| 30 | + event.preventDefault(); | |
| 31 | + searchInput.focus(); | |
| 32 | +}); | |
| 33 | + | |
| 34 | +document.querySelectorAll("[data-ref-filter]").forEach((input) => { | |
| 35 | + const menu = input.closest(".ref-menu"); | |
| 36 | + const options = [...menu.querySelectorAll("[data-ref-option]")]; | |
| 37 | + const emptyState = menu.querySelector("[data-ref-empty]"); | |
| 38 | + input.addEventListener("input", () => { | |
| 39 | + const query = input.value.trim().toLocaleLowerCase("en"); | |
| 40 | + let visible = 0; | |
| 41 | + options.forEach((option) => { | |
| 42 | + option.hidden = !option.dataset.refName.includes(query); | |
| 43 | + if (!option.hidden) visible += 1; | |
| 44 | + }); | |
| 45 | + emptyState.hidden = visible > 0; | |
| 46 | + }); | |
| 47 | + input.addEventListener("keydown", (event) => { | |
| 48 | + if (event.key !== "Escape") return; | |
| 49 | + const details = input.closest("details"); | |
| 50 | + details.open = false; | |
| 51 | + details.querySelector("summary").focus(); | |
| 52 | + }); | |
| 53 | +}); | |
| 54 | + | |
| 55 | +document.addEventListener("click", (event) => { | |
| 56 | + document.querySelectorAll(".ref-switcher[open]").forEach((details) => { | |
| 57 | + if (!details.contains(event.target)) details.open = false; | |
| 58 | + }); | |
| 7 | 59 | }); |
| 8 | 60 | |
| 9 | 61 | const shareForm = document.querySelector(".share-form"); |
| @@ -28,3 +80,9 @@shareForm?.addEventListener("submit", () => { | ||
| 28 | 80 | createButton.textContent = "Creating snapshot..."; |
| 29 | 81 | creationStatus.textContent = "Creating the snapshot. Large repositories may take a few minutes."; |
| 30 | 82 | }); |
| 83 | + | |
| 84 | +document.querySelectorAll("form[data-confirm]").forEach((form) => { | |
| 85 | + form.addEventListener("submit", (event) => { | |
| 86 | + if (!window.confirm(form.dataset.confirm)) event.preventDefault(); | |
| 87 | + }); | |
| 88 | +}); |
modified src/public/styles.css +361 −29
| @@ -15,10 +15,10 @@ | ||
| 15 | 15 | } |
| 16 | 16 | |
| 17 | 17 | * { box-sizing: border-box; } |
| 18 | -html { min-width: 1024px; background: var(--paper); } | |
| 18 | +html { background: var(--paper); } | |
| 19 | 19 | body { margin: 0; color: var(--ink); font-family: "Segoe UI", Arial, sans-serif; line-height: 1.5; } |
| 20 | 20 | a { color: inherit; } |
| 21 | -button, input { font: inherit; } | |
| 21 | +button, input, select { font: inherit; } | |
| 22 | 22 | .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } |
| 23 | 23 | .wordmark { font-family: Georgia, serif; font-size: 22px; font-weight: 700; letter-spacing: -.04em; text-decoration: none; } |
| 24 | 24 | .wordmark span { color: var(--blue); } |
| @@ -29,14 +29,21 @@button, input { font: inherit; } | ||
| 29 | 29 | .button-primary { background: var(--blue); color: white; box-shadow: 0 6px 18px rgba(49, 88, 212, .2); } |
| 30 | 30 | .button-primary:hover { background: var(--blue-dark); } |
| 31 | 31 | .button-secondary { border-color: var(--line); background: white; color: var(--ink); } |
| 32 | +.button-danger { border-color: #cf222e; background: #cf222e; color: white; } | |
| 33 | +.button-danger:hover { background: #a40e26; } | |
| 34 | +.button-small { min-height: 34px; padding: 0 12px; font-size: 12px; } | |
| 32 | 35 | .button:disabled { opacity: .45; cursor: not-allowed; } |
| 33 | 36 | .text-link { color: var(--blue); font-weight: 700; text-decoration: none; } |
| 34 | 37 | a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid var(--cyan); outline-offset: 3px; } |
| 35 | 38 | |
| 36 | 39 | .owner-page { min-height: 100vh; background: radial-gradient(circle at 82% 10%, rgba(117, 213, 232, .2), transparent 24%), var(--paper); } |
| 37 | 40 | .owner-header { height: 74px; display: flex; align-items: center; justify-content: space-between; padding: 0 5vw; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.82); } |
| 41 | +.owner-header-actions { display: flex; align-items: center; gap: 18px; } | |
| 38 | 42 | .owner-identity { display: flex; align-items: center; gap: 10px; font-weight: 700; } |
| 39 | 43 | .owner-identity img { width: 32px; height: 32px; border-radius: 50%; } |
| 44 | +.owner-header-actions form { margin: 0; } | |
| 45 | +.owner-signout { padding: 0; border: 0; background: transparent; color: var(--ink-soft); font-weight: 600; cursor: pointer; } | |
| 46 | +.owner-signout:hover { color: var(--ink); } | |
| 40 | 47 | .owner-shell { width: min(1180px, 90vw); margin: 64px auto; } |
| 41 | 48 | .welcome-panel { width: min(730px, 65vw); padding: 70px; border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,255,.94); box-shadow: var(--shadow); } |
| 42 | 49 | .welcome-panel h1 { max-width: 620px; margin: 0; font: 700 54px/1.05 Georgia, serif; letter-spacing: -.045em; } |
| @@ -45,6 +52,7 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 45 | 52 | .privacy-note strong { color: var(--ink); } |
| 46 | 53 | .notice { padding: 13px 16px; border: 1px solid #c7d6f3; border-radius: 7px; background: #edf3ff; } |
| 47 | 54 | .notice-error { border-color: #f0b9bf; background: #fff0f1; color: #8d2631; } |
| 55 | +.notice-success { border-color: #a9d6b8; background: #dafbe1; color: #116329; } | |
| 48 | 56 | .setup-panel { max-width: 660px; padding: 50px; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: var(--shadow); } |
| 49 | 57 | .setup-panel h1, .workspace-heading h1 { margin: 0 0 12px; font: 700 40px/1.1 Georgia, serif; letter-spacing: -.035em; } |
| 50 | 58 | .setup-panel p:not(.step-label) { color: var(--ink-soft); font-size: 17px; } |
| @@ -75,6 +83,23 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 75 | 83 | .publish-panel p:not(.step-label) { color: #b6c1d5; font-size: 13px; } |
| 76 | 84 | .publish-panel .selection-count { color: white; font-weight: 700; } |
| 77 | 85 | .publish-panel .button { width: 100%; margin-top: 8px; } |
| 86 | +.snapshot-list-section { margin-top: 62px; scroll-margin-top: 24px; } | |
| 87 | +.snapshot-list-heading { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 14px; } | |
| 88 | +.snapshot-list-heading h2 { margin: 0; font: 700 30px/1.2 Georgia, serif; } | |
| 89 | +.snapshot-list-heading > span { color: var(--ink-soft); font-size: 13px; } | |
| 90 | +.snapshot-list { overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: white; } | |
| 91 | +.snapshot-list-item { min-height: 132px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 22px; border-bottom: 1px solid var(--line); } | |
| 92 | +.snapshot-list-item:last-child { border-bottom: 0; } | |
| 93 | +.snapshot-list-main { min-width: 0; } | |
| 94 | +.snapshot-list-title { display: flex; align-items: center; gap: 10px; } | |
| 95 | +.snapshot-list-title h3 { margin: 0; overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; } | |
| 96 | +.snapshot-status { padding: 2px 7px; border: 1px solid var(--line); border-radius: 20px; color: var(--ink-soft); font-size: 10px; font-weight: 700; text-transform: capitalize; } | |
| 97 | +.snapshot-status-active { border-color: #a9d6b8; background: #dafbe1; color: #116329; } | |
| 98 | +.snapshot-status-revoked { border-color: #f0b9bf; background: #ffebe9; color: #a40e26; } | |
| 99 | +.snapshot-list-main p { margin: 7px 0; color: var(--ink-soft); font-size: 12px; } | |
| 100 | +.snapshot-list-main code { display: block; max-width: 620px; overflow: hidden; color: #57606a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } | |
| 101 | +.snapshot-list-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 7px; } | |
| 102 | +.snapshot-list-actions form { margin: 0; } | |
| 78 | 103 | .center-shell { min-height: 100vh; display: grid; place-items: center; padding: 50px; } |
| 79 | 104 | .created-panel { width: min(720px, 80vw); padding: 56px; border: 1px solid var(--line); border-radius: 18px; background: white; box-shadow: var(--shadow); } |
| 80 | 105 | .created-panel h1 { margin: 15px 0; font: 700 44px/1.1 Georgia, serif; } |
| @@ -91,42 +116,72 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 91 | 116 | |
| 92 | 117 | .viewer-page { min-height: 100vh; background: #f4f6fa; } |
| 93 | 118 | .viewer-header { height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 4vw; background: var(--viewer); color: #b8c3d7; } |
| 119 | +.viewer-header > .wordmark { flex: 0 0 auto; } | |
| 120 | +.snapshot-search { width: min(440px, 40vw); height: 34px; display: flex; align-items: center; gap: 8px; margin: 0 28px; border: 1px solid #526078; border-radius: 6px; background: #0c1422; color: #8c98ad; } | |
| 121 | +.snapshot-search:focus-within { border-color: #75d5e8; box-shadow: 0 0 0 2px rgba(117, 213, 232, .18); } | |
| 122 | +.snapshot-search svg { flex: 0 0 auto; margin-left: 10px; fill: currentColor; } | |
| 123 | +.snapshot-search input:not([type="hidden"]) { min-width: 0; height: 100%; flex: 1; padding: 0; border: 0; outline: 0; background: transparent; color: white; font-size: 13px; } | |
| 124 | +.snapshot-search input::placeholder { color: #8c98ad; opacity: 1; } | |
| 125 | +.snapshot-search kbd { margin-right: 7px; padding: 0 5px 1px; border: 1px solid #526078; border-radius: 4px; color: #9eabc0; font: 11px/17px "Segoe UI", Arial, sans-serif; } | |
| 126 | +.snapshot-search button:not(.sr-only) { align-self: stretch; padding: 0 13px; border: 0; border-left: 1px solid #526078; background: #202b3d; color: white; font-size: 12px; font-weight: 600; cursor: pointer; } | |
| 127 | +.snapshot-search button:not(.sr-only):hover { background: #2b384d; } | |
| 94 | 128 | .snapshot-meta, .top-nav { display: flex; align-items: center; gap: 10px; font-size: 12px; } |
| 129 | +.snapshot-meta, .top-nav { flex: 0 0 auto; } | |
| 95 | 130 | .top-nav a { color: white; font-weight: 700; text-decoration: none; } |
| 96 | 131 | .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 0 4px rgba(117, 213, 232, .1); } |
| 97 | 132 | .meta-divider { width: 1px; height: 15px; background: #40506b; } |
| 98 | -.profile-shell { width: min(1260px, 92vw); display: grid; grid-template-columns: 285px minmax(0, 1fr); gap: 58px; margin: 58px auto 90px; align-items: start; } | |
| 99 | -.profile-card { position: sticky; top: 30px; padding: 28px; border: 1px solid var(--line); border-radius: 12px; background: white; box-shadow: 0 10px 30px rgba(24, 43, 78, .07); } | |
| 100 | -.profile-avatar { width: 108px; height: 108px; margin-bottom: 24px; border-radius: 12px; object-fit: cover; filter: saturate(.85); } | |
| 101 | -.profile-card h1 { margin: 0; font: 700 31px/1.08 Georgia, serif; letter-spacing: -.035em; } | |
| 102 | -.profile-login { margin: 5px 0 18px; color: var(--ink-soft); } | |
| 103 | -.profile-bio { font-size: 14px; } | |
| 104 | -.profile-count { display: flex; align-items: baseline; gap: 8px; margin-top: 25px; padding-top: 18px; border-top: 1px solid var(--line); } | |
| 105 | -.profile-count strong { font: 700 29px Georgia, serif; } | |
| 106 | -.profile-count span { color: var(--ink-soft); font-size: 12px; } | |
| 107 | -.section-heading { display: flex; align-items: end; justify-content: space-between; margin-bottom: 20px; } | |
| 108 | -.section-heading h2 { margin: 0; font: 700 27px Georgia, serif; letter-spacing: -.025em; } | |
| 133 | +.profile-tabs { height: 52px; border-bottom: 1px solid #d8dee4; background: #fff; } | |
| 134 | +.profile-tabs > div { width: min(1180px, 92vw); height: 100%; display: flex; align-items: stretch; gap: 4px; margin: 0 auto; padding-left: 292px; } | |
| 135 | +.profile-tabs a { position: relative; display: flex; align-items: center; gap: 7px; padding: 0 14px; color: #1f2328; font-size: 14px; text-decoration: none; } | |
| 136 | +.profile-tabs a:hover { background: #f6f8fa; } | |
| 137 | +.profile-tabs a.active:after { position: absolute; right: 9px; bottom: -1px; left: 9px; height: 2px; border-radius: 2px; background: #fd8c73; content: ""; } | |
| 138 | +.profile-tabs a span { min-width: 22px; padding: 0 6px; border-radius: 20px; background: #eaeef2; font-size: 12px; font-weight: 600; text-align: center; } | |
| 139 | +.profile-shell { width: min(1180px, 92vw); display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 32px; margin: 32px auto 90px; align-items: start; } | |
| 140 | +.profile-card { position: sticky; top: 24px; } | |
| 141 | +.profile-avatar { width: 260px; height: 260px; display: block; margin-bottom: 18px; border: 1px solid #d0d7de; border-radius: 50%; object-fit: cover; } | |
| 142 | +.profile-card h1 { margin: 0; color: #1f2328; font: 600 26px/1.25 "Segoe UI", Arial, sans-serif; } | |
| 143 | +.profile-login { margin: 0 0 17px; color: #636c76; font-size: 20px; font-weight: 300; } | |
| 144 | +.profile-bio { margin: 0; color: #1f2328; font-size: 14px; } | |
| 145 | +.profile-count { display: flex; align-items: baseline; gap: 6px; margin-top: 18px; color: #636c76; font-size: 12px; } | |
| 146 | +.profile-count strong { color: #1f2328; font-size: 14px; } | |
| 147 | +.profile-content { min-width: 0; } | |
| 148 | +.section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } | |
| 149 | +.section-heading h2 { margin: 0; color: #1f2328; font: 500 16px/1.4 "Segoe UI", Arial, sans-serif; } | |
| 150 | +.profile-section-link { color: #0969da; font-size: 12px; text-decoration: none; } | |
| 109 | 151 | .read-only-badge { padding: 5px 9px; border: 1px solid #bfd1c9; border-radius: 20px; color: var(--green); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } |
| 110 | 152 | .repo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } |
| 111 | -.repo-card { display: flex; min-height: 210px; flex-direction: column; padding: 23px; border: 1px solid var(--line); border-radius: 10px; background: white; color: inherit; text-decoration: none; transition: transform .15s, border-color .15s, box-shadow .15s; } | |
| 112 | -.repo-card:hover { border-color: #9badd9; box-shadow: 0 12px 28px rgba(32, 57, 110, .09); transform: translateY(-2px); } | |
| 113 | -.repo-card-top { display: flex; justify-content: space-between; color: var(--blue); } | |
| 114 | -.folder-icon { font-size: 23px; font-weight: 700; } | |
| 115 | -.language { color: var(--ink-soft); font-size: 11px; text-transform: uppercase; } | |
| 116 | -.repo-card h3 { margin: 20px 0 7px; font: 700 21px Georgia, serif; } | |
| 117 | -.repo-card p { margin: 0; color: var(--ink-soft); font-size: 14px; } | |
| 118 | -.repo-card-foot { display: flex; gap: 16px; margin-top: auto; padding-top: 19px; color: #728098; font-size: 12px; } | |
| 119 | -.open-mark { margin-left: auto; color: var(--blue); font-weight: 700; } | |
| 120 | -.activity-section { margin-top: 55px; } | |
| 153 | +.repo-card { display: flex; min-height: 150px; flex-direction: column; padding: 16px; border: 1px solid #d0d7de; border-radius: 6px; background: white; color: inherit; text-decoration: none; } | |
| 154 | +.repo-card:hover { border-color: #0969da; } | |
| 155 | +.repo-card-top { display: flex; justify-content: space-between; color: #636c76; } | |
| 156 | +.language { display: inline-flex; align-items: center; gap: 6px; color: #636c76; font-size: 12px; } | |
| 157 | +.language i { width: 10px; height: 10px; border-radius: 50%; background: #3178c6; } | |
| 158 | +.repo-card h3 { margin: 14px 0 7px; color: #0969da; font: 600 14px/1.4 "Segoe UI", Arial, sans-serif; } | |
| 159 | +.repo-card p { margin: 0; color: #636c76; font-size: 12px; } | |
| 160 | +.repo-card-foot { display: flex; gap: 16px; margin-top: auto; padding-top: 15px; color: #636c76; font-size: 12px; } | |
| 161 | +.activity-section { margin-top: 38px; } | |
| 121 | 162 | .activity-list { position: relative; margin: 0; padding: 0; list-style: none; } |
| 122 | -.activity-list:before { position: absolute; top: 11px; bottom: 11px; left: 5px; width: 1px; background: #cbd5e5; content: ""; } | |
| 163 | +.activity-list:before { position: absolute; top: 11px; bottom: 11px; left: 5px; width: 1px; background: #d0d7de; content: ""; } | |
| 123 | 164 | .activity-list li { position: relative; display: flex; gap: 19px; padding: 0 0 22px; } |
| 124 | -.activity-node { z-index: 1; width: 11px; height: 11px; flex: 0 0 auto; margin-top: 6px; border: 3px solid #f4f6fa; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 1px var(--blue); } | |
| 165 | +.activity-node { z-index: 1; width: 11px; height: 11px; flex: 0 0 auto; margin-top: 6px; border: 3px solid #f4f6fa; border-radius: 50%; background: #1f883d; box-shadow: 0 0 0 1px #1f883d; } | |
| 125 | 166 | .activity-list strong, .activity-list span { display: block; } |
| 126 | 167 | .activity-list a { color: inherit; text-decoration: none; } |
| 127 | -.activity-list a:hover strong { color: var(--blue); } | |
| 168 | +.activity-list a:hover strong { color: #0969da; } | |
| 128 | 169 | .activity-list strong { font-size: 14px; } |
| 129 | -.activity-list span { margin-top: 3px; color: var(--ink-soft); font-size: 12px; } | |
| 170 | +.activity-list span { margin-top: 3px; color: #636c76; font-size: 12px; } | |
| 171 | +.repository-heading { margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #d8dee4; } | |
| 172 | +.repository-filter { display: grid; grid-template-columns: minmax(0, 1fr) 150px 160px auto; gap: 8px; padding-bottom: 16px; border-bottom: 1px solid #d8dee4; } | |
| 173 | +.repository-filter input, .repository-filter select { min-width: 0; height: 32px; padding: 0 10px; border: 1px solid #d0d7de; border-radius: 6px; background: #fff; color: #1f2328; font-size: 13px; } | |
| 174 | +.repository-filter input:focus, .repository-filter select:focus { border-color: #0969da; outline: 2px solid rgba(9, 105, 218, .2); outline-offset: -1px; } | |
| 175 | +.repository-filter .quiet-button { border-color: #1f883d; background: #1f883d; color: #fff; cursor: pointer; } | |
| 176 | +.repository-list article { min-height: 134px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 24px 0; border-bottom: 1px solid #d8dee4; } | |
| 177 | +.repository-list-main { min-width: 0; } | |
| 178 | +.repository-list-title { display: flex; align-items: center; gap: 9px; } | |
| 179 | +.repository-list-title > a { color: #0969da; font-size: 20px; font-weight: 600; text-decoration: none; } | |
| 180 | +.repository-list-main > p { margin: 8px 0 16px; color: #636c76; font-size: 13px; } | |
| 181 | +.repository-list-meta { display: flex; flex-wrap: wrap; gap: 18px; color: #636c76; font-size: 12px; } | |
| 182 | +.repository-list article > .quiet-button { flex: 0 0 auto; margin-top: 3px; } | |
| 183 | +.repository-list .empty-state { border-bottom: 1px solid #d8dee4; } | |
| 184 | +.repository-list .empty-state a { display: block; margin-top: 6px; color: #0969da; text-decoration: none; } | |
| 130 | 185 | |
| 131 | 186 | .repository-page { background: #f6f8fa; color: #1f2328; } |
| 132 | 187 | .repo-mast { border-bottom: 1px solid #d8dee4; background: #fff; } |
| @@ -153,8 +208,30 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 153 | 208 | .repo-tabs .repo-icon { color: #636c76; } |
| 154 | 209 | .tab-count { min-width: 22px; padding: 0 6px; border-radius: 20px; background: #eaeef2; font-size: 12px; font-weight: 600; text-align: center; } |
| 155 | 210 | .repository-shell { width: min(1180px, 92vw); margin: 24px auto 80px; } |
| 156 | -.code-toolbar { height: 34px; display: flex; align-items: center; gap: 14px; margin-bottom: 12px; } | |
| 211 | +.code-toolbar { position: relative; height: 34px; display: flex; align-items: center; gap: 14px; margin-bottom: 12px; } | |
| 157 | 212 | .branch-pill, .quiet-button { height: 32px; display: inline-flex; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid #d0d7de; border-radius: 6px; background: #f6f8fa; color: #24292f; font-size: 13px; font-weight: 600; line-height: 1; text-decoration: none; box-shadow: 0 1px 0 rgba(27,31,36,.04); } |
| 213 | +.ref-switcher { position: relative; } | |
| 214 | +.ref-switcher summary { list-style: none; cursor: pointer; } | |
| 215 | +.ref-switcher summary::-webkit-details-marker { display: none; } | |
| 216 | +.ref-switcher[open] summary { background: #eef1f4; } | |
| 217 | +.ref-chevron { margin-left: 3px; fill: #636c76; } | |
| 218 | +.ref-switcher[open] .ref-chevron { transform: rotate(180deg); } | |
| 219 | +.ref-menu { position: absolute; z-index: 20; top: 38px; left: 0; width: 340px; overflow: hidden; border: 1px solid #d0d7de; border-radius: 7px; background: #fff; box-shadow: 0 8px 24px rgba(140, 149, 159, .2); } | |
| 220 | +.ref-menu > header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 13px 14px; border-bottom: 1px solid #d8dee4; } | |
| 221 | +.ref-menu > header strong { font-size: 13px; } | |
| 222 | +.ref-menu > header span { color: #636c76; font-size: 11px; } | |
| 223 | +.ref-menu > input { width: calc(100% - 24px); height: 32px; margin: 12px; padding: 0 10px; border: 1px solid #d0d7de; border-radius: 6px; font-size: 12px; } | |
| 224 | +.ref-menu > input:focus { border-color: #0969da; outline: 2px solid rgba(9, 105, 218, .2); outline-offset: -1px; } | |
| 225 | +.ref-options { max-height: 330px; padding-bottom: 6px; overflow-y: auto; border-top: 1px solid #eaeef2; } | |
| 226 | +.ref-options > p:not(.ref-no-results) { margin: 0; padding: 9px 13px 5px; color: #636c76; font-size: 11px; font-weight: 600; text-transform: uppercase; } | |
| 227 | +.ref-options > a { min-height: 36px; display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 7px; align-items: center; padding: 0 13px; color: #1f2328; font-size: 12px; text-decoration: none; } | |
| 228 | +.ref-options > a:hover { background: #f6f8fa; } | |
| 229 | +.ref-options > a.selected { font-weight: 600; } | |
| 230 | +.ref-options > a > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 231 | +.ref-options small { padding: 1px 5px; border: 1px solid #d0d7de; border-radius: 20px; color: #636c76; font-size: 9px; font-weight: 500; } | |
| 232 | +.ref-check { position: relative; width: 14px; height: 14px; } | |
| 233 | +.ref-options > a.selected .ref-check:after { position: absolute; top: 0; left: 4px; width: 5px; height: 9px; border-right: 2px solid #1f883d; border-bottom: 2px solid #1f883d; content: ""; transform: rotate(45deg); } | |
| 234 | +.ref-no-results { margin: 0; padding: 22px 13px; color: #636c76; font-size: 12px; text-align: center; } | |
| 158 | 235 | .head-reference { color: #636c76; font-size: 12px; } |
| 159 | 236 | .head-reference code { color: #24292f; } |
| 160 | 237 | .commit-shortcut { display: flex; align-items: center; gap: 6px; margin-left: auto; color: #57606a; font-size: 13px; text-decoration: none; } |
| @@ -220,7 +297,26 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 220 | 297 | .file-view-header div { display: flex; gap: 10px; } |
| 221 | 298 | .file-view-header span { color: #636c76; } |
| 222 | 299 | .file-view-header a { color: #0969da; font-weight: 600; text-decoration: none; } |
| 223 | -.code-view { max-height: 760px; margin: 0; padding: 22px 26px; overflow: auto; background: #fff; color: #24292f; font: 13px/1.65 Consolas, monospace; tab-size: 2; } | |
| 300 | +.file-view-actions { align-items: center; } | |
| 301 | +.file-view-actions .copy-path { height: 30px; min-height: 30px; padding: 0 10px; cursor: pointer; } | |
| 302 | +.file-view-actions a.quiet-button { color: #24292f; } | |
| 303 | +.file-view-tabs { display: flex; gap: 4px; padding: 8px 12px 0; border-bottom: 1px solid #d8dee4; } | |
| 304 | +.file-view-tabs a { margin-bottom: -1px; padding: 8px 12px 9px; border-bottom: 2px solid transparent; color: #636c76; font-size: 13px; font-weight: 600; text-decoration: none; } | |
| 305 | +.file-view-tabs a:hover { color: #1f2328; } | |
| 306 | +.file-view-tabs a.active { border-bottom-color: #fd8c73; color: #1f2328; } | |
| 307 | +.markdown-file-preview { max-width: none; min-height: 180px; } | |
| 308 | +.image-file-preview { min-height: 260px; display: grid; place-items: center; padding: 28px; background-color: #f6f8fa; background-image: linear-gradient(45deg, #eaeef2 25%, transparent 25%), linear-gradient(-45deg, #eaeef2 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #eaeef2 75%), linear-gradient(-45deg, transparent 75%, #eaeef2 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; } | |
| 309 | +.image-file-preview img { display: block; max-width: 100%; max-height: 720px; } | |
| 310 | +.file-history-page .section-title-row h2 code { font: 13px Consolas, monospace; } | |
| 311 | +.code-view { max-height: 760px; overflow: auto; background: #fff; color: #24292f; font: 13px/1.65 Consolas, monospace; tab-size: 2; } | |
| 312 | +.code-table { width: max-content; min-width: 100%; border-spacing: 0; } | |
| 313 | +.code-table th { width: 1%; min-width: 52px; padding: 0 12px; background: #f6f8fa; color: #8c959f; font-weight: 400; text-align: right; user-select: none; vertical-align: top; } | |
| 314 | +.code-table th a { color: inherit; text-decoration: none; } | |
| 315 | +.code-table td { min-width: 100%; padding: 0 18px; white-space: pre; } | |
| 316 | +.code-table td code { font: inherit; } | |
| 317 | +.code-table tr:first-child th, .code-table tr:first-child td { padding-top: 18px; } | |
| 318 | +.code-table tr:last-child th, .code-table tr:last-child td { padding-bottom: 18px; } | |
| 319 | +.code-table tr:target th, .code-table tr:target td { background: #fff8c5; } | |
| 224 | 320 | .commit-detail-heading { min-height: 112px; display: flex; align-items: center; justify-content: space-between; padding: 22px 24px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; } |
| 225 | 321 | .commit-detail-heading h2 { margin: 4px 0 7px; font: 650 22px/1.3 "Segoe UI", Arial, sans-serif; } |
| 226 | 322 | .commit-detail-heading p { margin: 0; color: #636c76; font-size: 12px; } |
| @@ -236,13 +332,249 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 236 | 332 | .diff-file .deletion { background: #ffebe9; color: #82071e; } |
| 237 | 333 | .empty-state { margin: 0; padding: 32px; color: var(--ink-soft); text-align: center; } |
| 238 | 334 | |
| 335 | +.work-list-page, .discussion-page { color: #1f2328; } | |
| 336 | +.work-list-page { overflow: hidden; border: 1px solid #d0d7de; border-radius: 7px; background: #fff; } | |
| 337 | +.work-list-header { min-height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 16px 20px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; } | |
| 338 | +.work-list-header h2 { margin: 0; font-size: 20px; } | |
| 339 | +.work-list-header p { margin: 3px 0 0; color: #636c76; font-size: 12px; } | |
| 340 | +.work-list-header nav { display: flex; gap: 5px; } | |
| 341 | +.work-list-header nav a { display: flex; align-items: center; gap: 6px; padding: 6px 9px; border-radius: 6px; color: #636c76; font-size: 12px; text-decoration: none; } | |
| 342 | +.work-list-header nav a:hover, .work-list-header nav a.active { background: #eaeef2; color: #1f2328; } | |
| 343 | +.work-list-header nav a.active { font-weight: 600; } | |
| 344 | +.work-list-header nav span { min-width: 20px; padding: 0 5px; border-radius: 20px; background: #d8dee4; font-size: 10px; text-align: center; } | |
| 345 | +.work-list article { min-height: 92px; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 15px 18px; border-bottom: 1px solid #eaeef2; } | |
| 346 | +.work-list article:last-child { border-bottom: 0; } | |
| 347 | +.work-list article:hover { background: #f6f8fa; } | |
| 348 | +.work-state-icon { width: 16px; height: 16px; margin-top: 3px; border: 2px solid #1f883d; border-radius: 50%; } | |
| 349 | +.work-state-icon.closed { border-color: #8250df; background: #8250df; box-shadow: inset 0 0 0 3px white; } | |
| 350 | +.work-state-icon.merged { border-color: #8250df; transform: rotate(45deg); } | |
| 351 | +.work-state-icon.draft { border-color: #8c959f; border-style: dashed; } | |
| 352 | +.work-title { color: #1f2328; font-size: 15px; font-weight: 650; text-decoration: none; } | |
| 353 | +.work-title:hover { color: #0969da; } | |
| 354 | +.work-list article p { margin: 7px 0 0; color: #636c76; font-size: 11px; } | |
| 355 | +.work-labels { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; } | |
| 356 | +.work-label { display: inline-flex; min-height: 20px; align-items: center; padding: 1px 7px; border: 1px solid #b6c7d9; border-radius: 20px; background: #f1f6fb; color: #334968; font-size: 10px; font-weight: 600; } | |
| 357 | +.work-comments { align-self: center; color: #636c76; font-size: 11px; text-align: right; } | |
| 358 | +.work-comments strong { color: #1f2328; } | |
| 359 | +.work-detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 30px; padding-bottom: 20px; border-bottom: 1px solid #d8dee4; } | |
| 360 | +.work-detail-heading h2 { margin: 0 0 10px; font: 500 28px/1.3 "Segoe UI", Arial, sans-serif; } | |
| 361 | +.work-detail-heading h2 span { color: #636c76; font-weight: 300; } | |
| 362 | +.work-detail-heading p { margin: 0; color: #636c76; font-size: 12px; } | |
| 363 | +.work-detail-heading p strong { color: #1f2328; } | |
| 364 | +.state-badge { display: inline-flex; min-height: 26px; align-items: center; margin-right: 7px; padding: 2px 10px; border-radius: 20px; background: #1f883d; color: white; font-size: 11px; font-weight: 650; text-transform: capitalize; } | |
| 365 | +.state-badge.closed, .state-badge.merged { background: #8250df; } | |
| 366 | +.state-badge.draft { background: #636c76; } | |
| 367 | +.discussion-layout { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 24px; margin-top: 24px; align-items: start; } | |
| 368 | +.discussion-timeline { min-width: 0; } | |
| 369 | +.discussion-card { margin-bottom: 16px; overflow: hidden; border: 1px solid #d0d7de; border-radius: 7px; background: #fff; } | |
| 370 | +.discussion-card > header { min-height: 42px; display: flex; align-items: center; gap: 6px; padding: 0 14px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; font-size: 12px; } | |
| 371 | +.discussion-card > header span { color: #636c76; } | |
| 372 | +.discussion-body { padding: 18px; color: #1f2328; font-size: 14px; overflow-wrap: anywhere; } | |
| 373 | +.discussion-body > :first-child { margin-top: 0; } | |
| 374 | +.discussion-body > :last-child { margin-bottom: 0; } | |
| 375 | +.discussion-body a { color: #0969da; } | |
| 376 | +.discussion-body code { padding: 2px 4px; border-radius: 4px; background: #f0f2f4; font: 12px Consolas, monospace; } | |
| 377 | +.discussion-body pre { padding: 13px; overflow: auto; border-radius: 6px; background: #f6f8fa; } | |
| 378 | +.discussion-body pre code { padding: 0; background: transparent; } | |
| 379 | +.work-sidebar { border-top: 1px solid #d8dee4; color: #636c76; font-size: 12px; } | |
| 380 | +.work-sidebar section { padding: 14px 0; border-bottom: 1px solid #d8dee4; } | |
| 381 | +.work-sidebar section > strong, .work-sidebar section > span { display: block; } | |
| 382 | +.work-sidebar section > strong { margin-bottom: 8px; color: #1f2328; } | |
| 383 | +.pull-summary { min-height: 48px; display: flex; align-items: center; gap: 20px; margin-top: 14px; padding: 0 14px; border: 1px solid #d0d7de; border-radius: 7px; background: #f6f8fa; color: #636c76; font-size: 11px; } | |
| 384 | +.pull-summary code { color: #0969da; font: 11px Consolas, monospace; } | |
| 385 | +.pull-summary .commit-change { margin-left: auto; } | |
| 386 | +.pull-files { margin-top: 24px; } | |
| 387 | +.pull-files > h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 12px; font-size: 16px; } | |
| 388 | +.pull-files > h3 span { min-width: 22px; padding: 1px 6px; border-radius: 20px; background: #eaeef2; font-size: 11px; text-align: center; } | |
| 389 | +.pull-files .diff-file { margin: 0 0 16px; } | |
| 390 | +.release-list-page, .release-detail { color: #1f2328; } | |
| 391 | +.release-list-page { overflow: hidden; border: 1px solid #d0d7de; border-radius: 7px; background: #fff; } | |
| 392 | +.release-list-page > header { min-height: 84px; display: flex; align-items: center; justify-content: space-between; padding: 15px 20px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; } | |
| 393 | +.release-list-page > header h2 { margin: 0; font-size: 20px; } | |
| 394 | +.release-list-page > header p { margin: 3px 0 0; color: #636c76; font-size: 12px; } | |
| 395 | +.release-list-page > header > span { color: #636c76; font-size: 12px; } | |
| 396 | +.release-list-page > header > span strong { color: #1f2328; } | |
| 397 | +.release-list article { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 26px; padding: 24px 20px; border-bottom: 1px solid #d8dee4; } | |
| 398 | +.release-list article:last-child { border-bottom: 0; } | |
| 399 | +.release-list article > aside { display: flex; align-items: flex-start; flex-direction: column; gap: 8px; } | |
| 400 | +.release-list time { color: #636c76; font-size: 11px; } | |
| 401 | +.release-tag { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border: 1px solid #b6c7d9; border-radius: 5px; background: #f1f6fb; color: #334968; font: 600 11px Consolas, monospace; } | |
| 402 | +.release-list-title { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; } | |
| 403 | +.release-list-title > a { color: #0969da; font-size: 18px; font-weight: 650; text-decoration: none; } | |
| 404 | +.latest-release, .release-status { display: inline-flex; min-height: 20px; align-items: center; padding: 1px 7px; border: 1px solid #1f883d; border-radius: 20px; color: #1f883d; font-size: 9px; font-weight: 650; } | |
| 405 | +.release-status { border-color: #bf8700; color: #9a6700; } | |
| 406 | +.release-list article > div > p { margin: 10px 0; color: #4d5661; font-size: 13px; white-space: pre-line; } | |
| 407 | +.release-list article > div > span { color: #636c76; font-size: 11px; } | |
| 408 | +.release-detail { overflow: hidden; border: 1px solid #d0d7de; border-radius: 7px; background: #fff; } | |
| 409 | +.release-detail > header { min-height: 118px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 22px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; } | |
| 410 | +.release-kicker { display: flex; align-items: center; gap: 7px; } | |
| 411 | +.release-detail h2 { margin: 10px 0 6px; font-size: 24px; } | |
| 412 | +.release-detail > header p { margin: 0; color: #636c76; font-size: 12px; } | |
| 413 | +.release-body { padding: 28px 34px; border-bottom: 1px solid #d8dee4; font-size: 15px; } | |
| 414 | +.release-assets { padding: 22px 34px 28px; } | |
| 415 | +.release-assets h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 12px; font-size: 15px; } | |
| 416 | +.release-assets h3 span { min-width: 22px; padding: 1px 6px; border-radius: 20px; background: #eaeef2; font-size: 11px; text-align: center; } | |
| 417 | +.release-assets ul { margin: 0; padding: 0; border: 1px solid #d8dee4; border-radius: 6px; list-style: none; } | |
| 418 | +.release-assets li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; padding: 11px 13px; border-bottom: 1px solid #eaeef2; } | |
| 419 | +.release-assets li:last-child { border-bottom: 0; } | |
| 420 | +.release-assets li strong { font-size: 12px; } | |
| 421 | +.release-assets li code { color: #636c76; font: 11px Consolas, monospace; } | |
| 422 | +.release-assets li span { grid-row: 1 / span 2; grid-column: 2; align-self: center; color: #636c76; font-size: 10px; } | |
| 423 | +.release-assets > p { margin: 10px 0 0; color: #636c76; font-size: 11px; } | |
| 424 | +.repository-overview-layout { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 24px; align-items: start; } | |
| 425 | +.repository-code-column { min-width: 0; } | |
| 426 | +.repository-about { color: #1f2328; font-size: 12px; } | |
| 427 | +.repository-about > h2 { margin: 0 0 8px; font-size: 16px; } | |
| 428 | +.repository-about > p { margin: 0 0 10px; color: #4d5661; font-size: 13px; } | |
| 429 | +.about-homepage { display: block; margin-bottom: 12px; color: #0969da; overflow-wrap: anywhere; } | |
| 430 | +.about-topics { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 16px; } | |
| 431 | +.about-topics span { padding: 2px 8px; border-radius: 20px; background: #ddf4ff; color: #0969da; font-size: 10px; font-weight: 600; } | |
| 432 | +.archived-badge { display: inline-flex; margin-bottom: 14px; padding: 3px 8px; border: 1px solid #bf8700; border-radius: 5px; color: #9a6700; font-size: 10px; font-weight: 600; } | |
| 433 | +.about-stats { margin: 0; padding: 14px 0; border-top: 1px solid #d8dee4; border-bottom: 1px solid #d8dee4; list-style: none; } | |
| 434 | +.about-stats li { padding: 3px 0; color: #636c76; } | |
| 435 | +.about-stats strong { color: #1f2328; } | |
| 436 | +.about-section { padding: 16px 0; border-bottom: 1px solid #d8dee4; } | |
| 437 | +.about-section h3 { margin: 0 0 8px; font-size: 13px; } | |
| 438 | +.about-section p { margin: 0; color: #636c76; } | |
| 439 | +.language-breakdown { display: grid; gap: 6px; } | |
| 440 | +.language-breakdown > div { display: flex; justify-content: space-between; gap: 10px; color: #636c76; font-size: 11px; } | |
| 441 | +.language-breakdown span { color: #1f2328; } | |
| 442 | + | |
| 443 | +.search-shell { width: min(1180px, 92vw); display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 34px; margin: 36px auto 80px; align-items: start; } | |
| 444 | +.search-filters { position: sticky; top: 24px; display: flex; flex-direction: column; padding: 8px 0; } | |
| 445 | +.search-filters h2 { margin: 0 12px 10px; color: #636c76; font-size: 12px; font-weight: 600; } | |
| 446 | +.search-filters > a { min-height: 38px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-radius: 6px; color: #1f2328; font-size: 13px; text-decoration: none; } | |
| 447 | +.search-filters > a:hover { background: #eaeef2; } | |
| 448 | +.search-filters > a.active { background: #0969da; color: white; font-weight: 600; } | |
| 449 | +.search-filters > a span { min-width: 24px; padding: 1px 6px; border-radius: 20px; background: rgba(175, 184, 193, .25); font-size: 11px; text-align: center; } | |
| 450 | +.search-filters > a.active span { background: rgba(255, 255, 255, .22); } | |
| 451 | +.search-scope { margin: 20px 12px 0; padding-top: 17px; border-top: 1px solid #d8dee4; font-size: 12px; } | |
| 452 | +.search-scope strong, .search-scope span { display: block; } | |
| 453 | +.search-scope span { margin: 3px 0 9px; color: #636c76; } | |
| 454 | +.search-scope a { color: #0969da; text-decoration: none; } | |
| 455 | +.search-heading { margin-bottom: 18px; padding-bottom: 18px; border-bottom: 1px solid #d8dee4; } | |
| 456 | +.search-heading p { margin: 0 0 3px; color: #636c76; font-size: 12px; } | |
| 457 | +.search-heading h1 { margin: 0; font: 500 20px/1.4 "Segoe UI", Arial, sans-serif; } | |
| 458 | +.search-heading h1 strong { font-weight: 650; } | |
| 459 | +.search-empty { padding: 70px 30px; border: 1px dashed #afb8c1; border-radius: 7px; background: #fff; text-align: center; } | |
| 460 | +.search-empty strong { font-size: 17px; } | |
| 461 | +.search-empty p { margin: 5px 0 0; color: #636c76; font-size: 13px; } | |
| 462 | +.result-group { margin-bottom: 24px; overflow: hidden; border: 1px solid #d0d7de; border-radius: 7px; background: #fff; } | |
| 463 | +.result-group > header { height: 44px; display: flex; align-items: center; gap: 8px; padding: 0 16px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; } | |
| 464 | +.result-group > header h2 { margin: 0; font-size: 14px; } | |
| 465 | +.result-group > header span { padding: 1px 7px; border-radius: 20px; background: #eaeef2; font-size: 11px; } | |
| 466 | +.code-result, .commit-result, .repository-result, .work-search-result { padding: 16px 18px; border-bottom: 1px solid #eaeef2; } | |
| 467 | +.code-result:last-child, .commit-result:last-child, .repository-result:last-child, .work-search-result:last-child { border-bottom: 0; } | |
| 468 | +.result-path { display: flex; gap: 5px; color: #0969da; font-size: 13px; text-decoration: none; } | |
| 469 | +.result-path span:last-child { overflow-wrap: anywhere; } | |
| 470 | +.code-result-line { display: grid; grid-template-columns: 46px minmax(0, 1fr); margin-top: 10px; overflow: hidden; border: 1px solid #eaeef2; border-radius: 5px; color: #1f2328; font: 12px/1.6 Consolas, monospace; text-decoration: none; } | |
| 471 | +.code-result-line > span { padding: 7px 10px; background: #f6f8fa; color: #8c959f; text-align: right; } | |
| 472 | +.code-result-line code { overflow: hidden; padding: 7px 12px; text-overflow: ellipsis; white-space: pre; } | |
| 473 | +.code-result > p { margin: 8px 0 0; color: #636c76; font-size: 12px; } | |
| 474 | +.commit-result { display: flex; align-items: center; justify-content: space-between; gap: 20px; } | |
| 475 | +.commit-result a { min-width: 0; color: #1f2328; text-decoration: none; } | |
| 476 | +.commit-result strong, .commit-result span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 477 | +.commit-result strong { font-size: 13px; } | |
| 478 | +.commit-result span { margin-top: 4px; color: #636c76; font-size: 12px; } | |
| 479 | +.commit-result > code { flex: 0 0 auto; color: #0969da; font-size: 11px; } | |
| 480 | +.repository-result > a { color: #0969da; font-size: 15px; font-weight: 650; text-decoration: none; } | |
| 481 | +.repository-result p { margin: 5px 0 9px; color: #4d5661; font-size: 13px; } | |
| 482 | +.repository-result > span { color: #636c76; font-size: 11px; } | |
| 483 | +.work-search-result { position: relative; padding-right: 95px; } | |
| 484 | +.work-search-result > a { color: #0969da; text-decoration: none; } | |
| 485 | +.work-search-result > a strong, .work-search-result > a span { display: block; } | |
| 486 | +.work-search-result > a strong { font-size: 14px; } | |
| 487 | +.work-search-result > a span { margin-top: 2px; color: #636c76; font-size: 11px; } | |
| 488 | +.work-search-result > p { margin: 9px 0 0; color: #4d5661; font-size: 12px; } | |
| 489 | +.work-search-result > .state-badge { position: absolute; top: 16px; right: 18px; min-height: 22px; padding: 1px 8px; font-size: 10px; } | |
| 490 | +.work-search-result > .release-tag { position: absolute; top: 16px; right: 18px; } | |
| 491 | + | |
| 239 | 492 | .state-page { min-height: 100vh; display: grid; place-items: center; background: var(--viewer); color: white; } |
| 240 | 493 | .state-shell { max-width: 620px; padding: 50px; text-align: center; } |
| 241 | 494 | .state-code { color: var(--cyan); font-size: 12px; font-weight: 800; letter-spacing: .15em; text-transform: uppercase; } |
| 242 | 495 | .state-shell h1 { margin: 10px 0 15px; font: 700 46px/1.1 Georgia, serif; } |
| 243 | 496 | .state-shell > p:not(.state-code) { color: #b8c3d7; } |
| 244 | 497 | .state-shell .text-link { color: var(--cyan); } |
| 245 | 498 | |
| 499 | +@media (max-width: 900px) { | |
| 500 | + .owner-shell { width: min(720px, 92vw); margin: 40px auto; } | |
| 501 | + .share-form { grid-template-columns: 1fr; } | |
| 502 | + .publish-panel { position: static; } | |
| 503 | + .snapshot-list-item { align-items: flex-start; flex-direction: column; } | |
| 504 | + .viewer-header { height: auto; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 20px; padding: 12px 4vw; } | |
| 505 | + .snapshot-search { width: 100%; grid-row: 2; grid-column: 1 / -1; margin: 0; } | |
| 506 | + .top-nav { justify-self: end; } | |
| 507 | + .profile-tabs > div { padding-left: 0; overflow-x: auto; } | |
| 508 | + .profile-shell { grid-template-columns: 180px minmax(0, 1fr); gap: 24px; } | |
| 509 | + .profile-avatar { width: 180px; height: 180px; } | |
| 510 | + .repo-tabs-inner { overflow-x: auto; } | |
| 511 | + .repo-tabs a { flex: 0 0 auto; } | |
| 512 | + .repository-overview-layout, .discussion-layout, .search-shell { grid-template-columns: 1fr; } | |
| 513 | + .repository-about { padding-top: 20px; border-top: 1px solid #d8dee4; } | |
| 514 | + .search-filters { position: static; flex-direction: row; overflow-x: auto; } | |
| 515 | + .search-filters h2 { display: none; } | |
| 516 | + .search-filters > a { flex: 0 0 auto; } | |
| 517 | + .search-scope { margin: 0 0 0 12px; padding: 0 0 0 17px; border-top: 0; border-left: 1px solid #d8dee4; } | |
| 518 | +} | |
| 519 | + | |
| 520 | +@media (max-width: 640px) { | |
| 521 | + .owner-header { height: auto; gap: 16px; padding: 14px 4vw; } | |
| 522 | + .owner-header-actions { gap: 10px; } | |
| 523 | + .owner-header-actions > .text-link, .owner-identity span { display: none; } | |
| 524 | + .welcome-panel, .setup-panel, .created-panel { width: 100%; padding: 30px 24px; } | |
| 525 | + .welcome-panel h1, .created-panel h1 { font-size: 36px; } | |
| 526 | + .setup-panel h1, .workspace-heading h1 { font-size: 31px; } | |
| 527 | + .owner-shell { margin-top: 30px; } | |
| 528 | + .workspace-heading { align-items: flex-start; flex-direction: column; gap: 8px; } | |
| 529 | + .repo-selection { grid-template-columns: 1fr; } | |
| 530 | + .snapshot-list-actions { flex-wrap: wrap; } | |
| 531 | + .center-shell { padding: 20px; } | |
| 532 | + .copy-row, .created-actions { align-items: stretch; flex-direction: column; } | |
| 533 | + .copy-row input { min-height: 44px; } | |
| 534 | + .viewer-header > .wordmark { justify-self: start; } | |
| 535 | + .top-nav { grid-row: 1; grid-column: 2; justify-self: end; } | |
| 536 | + .top-nav span { display: none; } | |
| 537 | + .profile-shell { grid-template-columns: 1fr; margin-top: 24px; } | |
| 538 | + .profile-card { position: static; } | |
| 539 | + .profile-avatar { width: 104px; height: 104px; } | |
| 540 | + .repo-grid { grid-template-columns: 1fr; } | |
| 541 | + .repository-filter { grid-template-columns: 1fr 1fr; } | |
| 542 | + .repository-filter input { grid-column: 1 / -1; } | |
| 543 | + .repository-list article { flex-direction: column; gap: 12px; } | |
| 544 | + .repo-mast-inner, .repo-tabs-inner, .repository-shell { width: 92vw; } | |
| 545 | + .repo-identity { align-items: flex-start; flex-wrap: wrap; } | |
| 546 | + .repo-identity h1 { min-width: calc(100% - 32px); font-size: 19px; } | |
| 547 | + .repo-facts { flex-wrap: wrap; gap: 7px 16px; } | |
| 548 | + .repo-tabs-inner { height: 47px; } | |
| 549 | + .repo-tabs a { padding: 0 10px; white-space: nowrap; } | |
| 550 | + .code-toolbar { height: auto; flex-wrap: wrap; } | |
| 551 | + .commit-shortcut { margin-left: 0; } | |
| 552 | + .latest-commit { grid-template-columns: 28px minmax(0, 1fr) auto; } | |
| 553 | + .latest-commit > code, .latest-commit > time { display: none; } | |
| 554 | + .file-row { grid-template-columns: 24px minmax(0, 1fr); } | |
| 555 | + .file-row > span:last-child { display: none; } | |
| 556 | + .markdown-body { padding: 26px 18px 34px; } | |
| 557 | + .markdown-body h1 { font-size: 25px; } | |
| 558 | + .file-view-header, .section-title-row, .commit-detail-heading, .work-list-header, .work-detail-heading, .release-detail > header { align-items: flex-start; flex-direction: column; gap: 12px; padding: 15px; } | |
| 559 | + .file-view-actions { flex-wrap: wrap; } | |
| 560 | + .commit-feed li { grid-template-columns: 28px minmax(0, 1fr) auto; } | |
| 561 | + .commit-feed .commit-change { display: none; } | |
| 562 | + .diff-file { margin: 12px; } | |
| 563 | + .work-list-header nav { width: 100%; overflow-x: auto; } | |
| 564 | + .work-list-header nav a { flex: 0 0 auto; } | |
| 565 | + .work-list article { grid-template-columns: 22px minmax(0, 1fr); } | |
| 566 | + .work-comments { display: none; } | |
| 567 | + .pull-summary { align-items: flex-start; flex-wrap: wrap; padding: 12px; } | |
| 568 | + .pull-summary .commit-change { margin-left: 0; } | |
| 569 | + .release-list article { grid-template-columns: 1fr; gap: 14px; } | |
| 570 | + .release-body, .release-assets { padding: 22px 18px; } | |
| 571 | + .search-shell { width: 92vw; margin-top: 22px; } | |
| 572 | + .search-scope { display: none; } | |
| 573 | + .search-filters > a { min-height: 34px; } | |
| 574 | + .state-shell { padding: 30px 20px; } | |
| 575 | + .state-shell h1 { font-size: 35px; } | |
| 576 | +} | |
| 577 | + | |
| 246 | 578 | @media (prefers-reduced-motion: reduce) { |
| 247 | 579 | *, *:before, *:after { scroll-behavior: auto !important; transition: none !important; } |
| 248 | 580 | } |
modified src/views/created.ejs +10 −1
| @@ -9,7 +9,15 @@ | ||
| 9 | 9 | <p>Open the snapshot and confirm every selected repository and all commits appear.</p> |
| 10 | 10 | <ul> |
| 11 | 11 | <% repositories.forEach((repository) => { %> |
| 12 | - <li><strong><%= repository.name %></strong><span><%= repository.commits.length %> <%= repository.commits.length === 1 ? "commit" : "commits" %></span></li> | |
| 12 | + <li> | |
| 13 | + <strong><%= repository.name %></strong> | |
| 14 | + <span> | |
| 15 | + <%= repository.commits.length %> <%= repository.commits.length === 1 ? "commit" : "commits" %> | |
| 16 | + · <%= repository.issues?.length || 0 %> issues | |
| 17 | + · <%= repository.pullRequests?.length || 0 %> pull requests | |
| 18 | + · <%= repository.releases?.length || 0 %> releases | |
| 19 | + </span> | |
| 20 | + </li> | |
| 13 | 21 | <% }) %> |
| 14 | 22 | </ul> |
| 15 | 23 | </section> |
| @@ -20,6 +28,7 @@ | ||
| 20 | 28 | <div class="created-actions"> |
| 21 | 29 | <a class="button button-secondary" href="<%= url %>">Review snapshot before sharing</a> |
| 22 | 30 | <a class="text-link" href="/">Create another</a> |
| 31 | + <a class="text-link" href="/#shared-links">Manage links</a> | |
| 23 | 32 | </div> |
| 24 | 33 | </section> |
| 25 | 34 | </main> |
modified src/views/dashboard.ejs +58 −3
| @@ -2,15 +2,22 @@ | ||
| 2 | 2 | <header class="owner-header"> |
| 3 | 3 | <a class="wordmark" href="/">profile<span>Share</span></a> |
| 4 | 4 | <% if (owner) { %> |
| 5 | - <div class="owner-identity"> | |
| 6 | - <img src="<%= owner.avatar_url %>" alt=""> | |
| 7 | - <span><%= owner.name || owner.login %></span> | |
| 5 | + <div class="owner-header-actions"> | |
| 6 | + <a class="text-link" href="#shared-links">Your links</a> | |
| 7 | + <div class="owner-identity"> | |
| 8 | + <img src="<%= owner.avatar_url %>" alt=""> | |
| 9 | + <span><%= owner.name || owner.login %></span> | |
| 10 | + </div> | |
| 11 | + <form method="post" action="/auth/logout"> | |
| 12 | + <button class="owner-signout" type="submit">Sign out</button> | |
| 13 | + </form> | |
| 8 | 14 | </div> |
| 9 | 15 | <% } %> |
| 10 | 16 | </header> |
| 11 | 17 | |
| 12 | 18 | <main class="owner-shell"> |
| 13 | 19 | <% if (error) { %><p class="notice notice-error"><%= error %></p><% } %> |
| 20 | + <% if (notice) { %><p class="notice notice-success"><%= notice %></p><% } %> | |
| 14 | 21 | <% if (!owner) { %> |
| 15 | 22 | <section class="welcome-panel"> |
| 16 | 23 | <p class="eyebrow">Private work, shared on your terms</p> |
| @@ -78,6 +85,54 @@ | ||
| 78 | 85 | </aside> |
| 79 | 86 | </form> |
| 80 | 87 | <% } %> |
| 88 | + | |
| 89 | + <% if (owner) { %> | |
| 90 | + <section class="snapshot-list-section" id="shared-links"> | |
| 91 | + <header class="snapshot-list-heading"> | |
| 92 | + <div> | |
| 93 | + <p class="eyebrow">Your links</p> | |
| 94 | + <h2>Shared snapshots</h2> | |
| 95 | + </div> | |
| 96 | + <span><%= shares.length %> <%= shares.length === 1 ? "link" : "links" %></span> | |
| 97 | + </header> | |
| 98 | + <div class="snapshot-list"> | |
| 99 | + <% shares.forEach((share) => { %> | |
| 100 | + <article class="snapshot-list-item"> | |
| 101 | + <div class="snapshot-list-main"> | |
| 102 | + <div class="snapshot-list-title"> | |
| 103 | + <h3> | |
| 104 | + <%= share.repositories.length | |
| 105 | + ? share.repositories.map((repository) => repository.name).join(", ") | |
| 106 | + : "Snapshot content removed" %> | |
| 107 | + </h3> | |
| 108 | + <span class="snapshot-status snapshot-status-<%= share.status %>"><%= share.status %></span> | |
| 109 | + </div> | |
| 110 | + <p> | |
| 111 | + Created <%= formatDate(share.created_at) %>. | |
| 112 | + Expires <%= formatDate(share.expires_at) %>. | |
| 113 | + <% if (share.repositories.length) { %> | |
| 114 | + <%= share.repositories.length %> <%= share.repositories.length === 1 ? "repository" : "repositories" %>. | |
| 115 | + <% } %> | |
| 116 | + </p> | |
| 117 | + <% if (share.status === "active") { %><code><%= share.url %></code><% } %> | |
| 118 | + </div> | |
| 119 | + <% if (share.status === "active") { %> | |
| 120 | + <div class="snapshot-list-actions"> | |
| 121 | + <a class="button button-secondary button-small" href="<%= share.url %>">Open</a> | |
| 122 | + <button class="button button-secondary button-small" type="button" data-copy-text="<%= share.url %>">Copy link</button> | |
| 123 | + <form method="post" action="/shares/<%= share.id %>/revoke" data-confirm="Revoke this snapshot link and remove its stored repository content?"> | |
| 124 | + <button class="button button-danger button-small" type="submit">Revoke</button> | |
| 125 | + </form> | |
| 126 | + </div> | |
| 127 | + <% } %> | |
| 128 | + </article> | |
| 129 | + <% }) %> | |
| 130 | + <% if (!shares.length) { %> | |
| 131 | + <p class="empty-state">No snapshot links have been created yet.</p> | |
| 132 | + <% } %> | |
| 133 | + </div> | |
| 134 | + </section> | |
| 135 | + <% } %> | |
| 81 | 136 | </main> |
| 82 | 137 | <script src="/assets/app.js" defer></script> |
| 83 | 138 | <%- include("partials/foot") %> |
modified src/views/expired.ejs +8 −3
| @@ -1,7 +1,12 @@ | ||
| 1 | -<%- include("partials/head", { title: "Link expired", bodyClass: "state-page" }) %> | |
| 1 | +<%- include("partials/head", { title: reason === "revoked" ? "Link revoked" : "Link expired", bodyClass: "state-page" }) %> | |
| 2 | 2 | <main class="state-shell"> |
| 3 | 3 | <p class="state-code">410</p> |
| 4 | - <h1>The URL you have opened is expired.</h1> | |
| 5 | - <p>The owner set a time limit for this snapshot, and its contents are no longer available.</p> | |
| 4 | + <% if (reason === "revoked") { %> | |
| 5 | + <h1>The owner revoked this snapshot link.</h1> | |
| 6 | + <p>Its stored repository content is no longer available.</p> | |
| 7 | + <% } else { %> | |
| 8 | + <h1>The URL you have opened is expired.</h1> | |
| 9 | + <p>The owner set a time limit for this snapshot, and its contents are no longer available.</p> | |
| 10 | + <% } %> | |
| 6 | 11 | </main> |
| 7 | 12 | <%- include("partials/foot") %> |
modified src/views/profile.ejs +112 −45
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | <%- include("partials/head", { title: share.snapshot.profile.name || share.snapshot.profile.login, bodyClass: "viewer-page" }) %> |
| 2 | 2 | <header class="viewer-header"> |
| 3 | 3 | <a class="wordmark wordmark-light" href="/s/<%= share.id %>">profile<span>Share</span></a> |
| 4 | + <form class="snapshot-search" action="/s/<%= share.id %>/search" method="get" role="search"> | |
| 5 | + <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M10.5 3a7.5 7.5 0 1 1-4.7 13.34l-3.07 3.07-1.42-1.42 3.08-3.07A7.5 7.5 0 0 1 10.5 3Zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Z"/></svg> | |
| 6 | + <input data-search-input name="q" aria-label="Search this snapshot" placeholder="Search this snapshot"> | |
| 7 | + <kbd>/</kbd> | |
| 8 | + <button class="sr-only" type="submit">Search</button> | |
| 9 | + </form> | |
| 4 | 10 | <div class="snapshot-meta"> |
| 5 | 11 | <span class="status-dot"></span> |
| 6 | 12 | Snapshot from <%= formatDate(share.created_at) %> |
| @@ -9,12 +15,23 @@ | ||
| 9 | 15 | </div> |
| 10 | 16 | </header> |
| 11 | 17 | |
| 18 | +<nav class="profile-tabs" aria-label="Profile sections"> | |
| 19 | + <div> | |
| 20 | + <a class="<%= tab === 'overview' ? 'active' : '' %>" href="/s/<%= share.id %>" <%- tab === "overview" ? 'aria-current="page"' : "" %>> | |
| 21 | + Overview | |
| 22 | + </a> | |
| 23 | + <a class="<%= tab === 'repositories' ? 'active' : '' %>" href="/s/<%= share.id %>?tab=repositories" <%- tab === "repositories" ? 'aria-current="page"' : "" %>> | |
| 24 | + Repositories | |
| 25 | + <span><%= share.snapshot.repositories.length %></span> | |
| 26 | + </a> | |
| 27 | + </div> | |
| 28 | +</nav> | |
| 29 | + | |
| 12 | 30 | <main class="profile-shell"> |
| 13 | 31 | <aside class="profile-card"> |
| 14 | 32 | <img class="profile-avatar" src="<%= share.snapshot.profile.avatarUrl %>" alt="<%= share.snapshot.profile.name || share.snapshot.profile.login %>"> |
| 15 | - <p class="eyebrow">Developer profile</p> | |
| 16 | 33 | <h1><%= share.snapshot.profile.name || share.snapshot.profile.login %></h1> |
| 17 | - <p class="profile-login">@<%= share.snapshot.profile.login %></p> | |
| 34 | + <p class="profile-login"><%= share.snapshot.profile.login %></p> | |
| 18 | 35 | <p class="profile-bio"><%= share.snapshot.profile.bio || "GitHub work selected for private review." %></p> |
| 19 | 36 | <div class="profile-count"> |
| 20 | 37 | <strong><%= share.snapshot.repositories.length %></strong> |
| @@ -23,56 +40,106 @@ | ||
| 23 | 40 | </aside> |
| 24 | 41 | |
| 25 | 42 | <section class="profile-content"> |
| 26 | - <div class="section-heading"> | |
| 27 | - <div> | |
| 28 | - <p class="eyebrow">Selected work</p> | |
| 29 | - <h2>Repositories in this snapshot</h2> | |
| 43 | + <% if (tab === "overview") { %> | |
| 44 | + <div class="section-heading"> | |
| 45 | + <h2>Selected repositories</h2> | |
| 46 | + <a class="profile-section-link" href="/s/<%= share.id %>?tab=repositories">View all repositories</a> | |
| 47 | + </div> | |
| 48 | + <div class="repo-grid"> | |
| 49 | + <% share.snapshot.repositories.forEach((repo) => { %> | |
| 50 | + <a class="repo-card" href="/s/<%= share.id %>/repositories/<%= repo.id %>"> | |
| 51 | + <div class="repo-card-top"> | |
| 52 | + <svg class="repo-icon repo-icon-file" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4.75A2.75 2.75 0 0 1 6.75 2h10.5A2.75 2.75 0 0 1 20 4.75v14.5A2.75 2.75 0 0 1 17.25 22H6.75A2.75 2.75 0 0 1 4 19.25V4.75Zm3.5-.75A1.5 1.5 0 0 0 6 5.5v13A1.5 1.5 0 0 0 7.5 20H9V4H7.5Z"/></svg> | |
| 53 | + <span class="repo-visibility"><%= repo.private === true ? "Private" : repo.private === false ? "Public" : "Snapshot" %></span> | |
| 54 | + </div> | |
| 55 | + <h3><%= repo.name %></h3> | |
| 56 | + <p><%= repo.description || "No repository description." %></p> | |
| 57 | + <div class="repo-card-foot"> | |
| 58 | + <span class="language"><i></i><%= repo.language || "Mixed" %></span> | |
| 59 | + <span><%= repo.commits.length %> commits</span> | |
| 60 | + <span><%= repo.issues?.filter((item) => item.state === "open").length || 0 %> open issues</span> | |
| 61 | + </div> | |
| 62 | + </a> | |
| 63 | + <% }) %> | |
| 64 | + <% if (!share.snapshot.repositories.length) { %> | |
| 65 | + <p class="empty-state">No repositories are included in this snapshot.</p> | |
| 66 | + <% } %> | |
| 30 | 67 | </div> |
| 31 | - <span class="read-only-badge">Read only</span> | |
| 32 | - </div> | |
| 33 | - <div class="repo-grid"> | |
| 34 | - <% share.snapshot.repositories.forEach((repo) => { %> | |
| 35 | - <a class="repo-card" href="/s/<%= share.id %>/repositories/<%= repo.id %>"> | |
| 36 | - <div class="repo-card-top"> | |
| 37 | - <span class="folder-icon">↳</span> | |
| 38 | - <span class="language"><%= repo.language || "Mixed" %></span> | |
| 39 | - </div> | |
| 40 | - <h3><%= repo.name %></h3> | |
| 41 | - <p><%= repo.description || "No repository description." %></p> | |
| 42 | - <div class="repo-card-foot"> | |
| 43 | - <span><%= repo.files.length %> files</span> | |
| 44 | - <span><%= repo.commits.length %> commits</span> | |
| 45 | - <span class="open-mark">Open →</span> | |
| 46 | - </div> | |
| 47 | - </a> | |
| 48 | - <% }) %> | |
| 49 | - <% if (!share.snapshot.repositories.length) { %> | |
| 50 | - <p class="empty-state">No repositories are included in this snapshot.</p> | |
| 51 | - <% } %> | |
| 52 | - </div> | |
| 53 | 68 | |
| 54 | - <section class="activity-section"> | |
| 55 | - <div class="section-heading"> | |
| 56 | - <div> | |
| 57 | - <p class="eyebrow">Profile activity</p> | |
| 69 | + <section class="activity-section"> | |
| 70 | + <div class="section-heading"> | |
| 58 | 71 | <h2>Recent work across the snapshot</h2> |
| 59 | 72 | </div> |
| 73 | + <ol class="activity-list"> | |
| 74 | + <% commits.slice(0, 12).forEach((commit) => { %> | |
| 75 | + <li> | |
| 76 | + <span class="activity-node"></span> | |
| 77 | + <a href="/s/<%= share.id %>/repositories/<%= commit.repositoryId %>?commit=<%= commit.sha %>"> | |
| 78 | + <strong><%= commit.message.split("\n")[0] %></strong> | |
| 79 | + <span><%= commit.repository %> · <%= formatDate(commit.date) %></span> | |
| 80 | + </a> | |
| 81 | + </li> | |
| 82 | + <% }) %> | |
| 83 | + <% if (!commits.length) { %> | |
| 84 | + <li class="empty-state">No commit activity is included in this snapshot.</li> | |
| 85 | + <% } %> | |
| 86 | + </ol> | |
| 87 | + </section> | |
| 88 | + <% } else { %> | |
| 89 | + <div class="section-heading repository-heading"> | |
| 90 | + <h2>Repositories</h2> | |
| 91 | + <span class="read-only-badge">Read only</span> | |
| 60 | 92 | </div> |
| 61 | - <ol class="activity-list"> | |
| 62 | - <% commits.slice(0, 12).forEach((commit) => { %> | |
| 63 | - <li> | |
| 64 | - <span class="activity-node"></span> | |
| 65 | - <a href="/s/<%= share.id %>/repositories/<%= commit.repositoryId %>?commit=<%= commit.sha %>"> | |
| 66 | - <strong><%= commit.message.split("\n")[0] %></strong> | |
| 67 | - <span><%= commit.repository %> · <%= formatDate(commit.date) %></span> | |
| 68 | - </a> | |
| 69 | - </li> | |
| 93 | + <form class="repository-filter" action="/s/<%= share.id %>" method="get"> | |
| 94 | + <input type="hidden" name="tab" value="repositories"> | |
| 95 | + <label class="sr-only" for="repository-query">Find a repository</label> | |
| 96 | + <input id="repository-query" name="q" type="search" value="<%= repositoryQuery %>" placeholder="Find a repository"> | |
| 97 | + <label class="sr-only" for="repository-language">Language</label> | |
| 98 | + <select id="repository-language" name="language"> | |
| 99 | + <option value="">All languages</option> | |
| 100 | + <% languages.forEach((language) => { %> | |
| 101 | + <option value="<%= language %>" <%= selectedLanguage === language ? "selected" : "" %>><%= language %></option> | |
| 102 | + <% }) %> | |
| 103 | + </select> | |
| 104 | + <label class="sr-only" for="repository-sort">Sort repositories</label> | |
| 105 | + <select id="repository-sort" name="sort"> | |
| 106 | + <option value="updated" <%= sort === "updated" ? "selected" : "" %>>Recently updated</option> | |
| 107 | + <option value="name" <%= sort === "name" ? "selected" : "" %>>Name</option> | |
| 108 | + <option value="commits" <%= sort === "commits" ? "selected" : "" %>>Most commits</option> | |
| 109 | + </select> | |
| 110 | + <button class="quiet-button" type="submit">Apply</button> | |
| 111 | + </form> | |
| 112 | + <div class="repository-list"> | |
| 113 | + <% repositories.forEach((repo) => { %> | |
| 114 | + <article> | |
| 115 | + <div class="repository-list-main"> | |
| 116 | + <div class="repository-list-title"> | |
| 117 | + <a href="/s/<%= share.id %>/repositories/<%= repo.id %>"><%= repo.name %></a> | |
| 118 | + <span class="repo-visibility"><%= repo.private === true ? "Private" : repo.private === false ? "Public" : "Snapshot" %></span> | |
| 119 | + </div> | |
| 120 | + <p><%= repo.description || "No repository description." %></p> | |
| 121 | + <div class="repository-list-meta"> | |
| 122 | + <span class="language"><i></i><%= repo.language || "Mixed" %></span> | |
| 123 | + <span><%= repo.files.length %> files</span> | |
| 124 | + <span><%= repo.commits.length %> commits</span> | |
| 125 | + <span><%= repo.issues?.length || 0 %> issues</span> | |
| 126 | + <span><%= repo.pullRequests?.length || 0 %> pull requests</span> | |
| 127 | + <span><%= repo.releases?.length || 0 %> releases</span> | |
| 128 | + <% if (repo.updatedAt) { %><span>Updated <%= formatDate(repo.updatedAt) %></span><% } %> | |
| 129 | + </div> | |
| 130 | + </div> | |
| 131 | + <a class="quiet-button" href="/s/<%= share.id %>/repositories/<%= repo.id %>">Open repository</a> | |
| 132 | + </article> | |
| 70 | 133 | <% }) %> |
| 71 | - <% if (!commits.length) { %> | |
| 72 | - <li class="empty-state">No commit activity is included in this snapshot.</li> | |
| 134 | + <% if (!repositories.length) { %> | |
| 135 | + <div class="empty-state"> | |
| 136 | + No repositories match these filters. | |
| 137 | + <a href="/s/<%= share.id %>?tab=repositories">Clear filters</a> | |
| 138 | + </div> | |
| 73 | 139 | <% } %> |
| 74 | - </ol> | |
| 75 | - </section> | |
| 140 | + </div> | |
| 141 | + <% } %> | |
| 76 | 142 | </section> |
| 77 | 143 | </main> |
| 144 | +<script src="/assets/app.js" defer></script> | |
| 78 | 145 | <%- include("partials/foot") %> |
modified src/views/repository.ejs +494 −36
| @@ -1,6 +1,17 @@ | ||
| 1 | 1 | <%- include("partials/head", { title: repository.name, bodyClass: "viewer-page repository-page" }) %> |
| 2 | 2 | <header class="viewer-header"> |
| 3 | 3 | <a class="wordmark wordmark-light" href="/s/<%= share.id %>">profile<span>Share</span></a> |
| 4 | + <form class="snapshot-search" action="/s/<%= share.id %>/search" method="get" role="search"> | |
| 5 | + <input type="hidden" name="repository" value="<%= repository.id %>"> | |
| 6 | + <% if (!selectedReference.isDefault) { %> | |
| 7 | + <input type="hidden" name="ref" value="<%= selectedReference.name %>"> | |
| 8 | + <input type="hidden" name="refType" value="<%= selectedReference.type %>"> | |
| 9 | + <% } %> | |
| 10 | + <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M10.5 3a7.5 7.5 0 1 1-4.7 13.34l-3.07 3.07-1.42-1.42 3.08-3.07A7.5 7.5 0 0 1 10.5 3Zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Z"/></svg> | |
| 11 | + <input data-search-input name="q" aria-label="Search <%= repository.name %>" placeholder="Search <%= repository.name %>"> | |
| 12 | + <kbd>/</kbd> | |
| 13 | + <button class="sr-only" type="submit">Search</button> | |
| 14 | + </form> | |
| 4 | 15 | <nav class="top-nav" aria-label="Snapshot navigation"> |
| 5 | 16 | <a href="/s/<%= share.id %>">← Back to profile</a> |
| 6 | 17 | <span class="meta-divider"></span> |
| @@ -23,7 +34,7 @@ | ||
| 23 | 34 | </div> |
| 24 | 35 | <p class="repo-summary"><%= repository.description || "No repository description." %></p> |
| 25 | 36 | <div class="repo-facts"> |
| 26 | - <span><strong><%= repository.defaultBranch || "No branch" %></strong> default branch</span> | |
| 37 | + <span><strong><%= selectedReference.name %></strong> <%= selectedReference.isDefault ? "default branch" : selectedReference.type %></span> | |
| 27 | 38 | <span><strong><%= repository.files.length %></strong> files</span> |
| 28 | 39 | <span>Expires <strong><%= formatDate(share.expires_at) %></strong></span> |
| 29 | 40 | </div> |
| @@ -32,11 +43,26 @@ | ||
| 32 | 43 | |
| 33 | 44 | <nav class="repo-tabs" aria-label="Repository sections"> |
| 34 | 45 | <div class="repo-tabs-inner"> |
| 35 | - <a class="<%= tab === 'code' && !commit ? 'active' : '' %>" href="/s/<%= share.id %>/repositories/<%= repository.id %>" <%- tab === "code" && !commit ? 'aria-current="page"' : "" %>> | |
| 46 | + <a class="<%= tab === 'code' && !commit ? 'active' : '' %>" href="<%= repoHref() %>" <%- tab === "code" && !commit ? 'aria-current="page"' : "" %>> | |
| 36 | 47 | <svg class="repo-icon repo-icon-tab" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="m8.7 17.3-5-5a1 1 0 0 1 0-1.4l5-5 1.4 1.4L5.8 11.6l4.3 4.3-1.4 1.4Zm6.6 0-1.4-1.4 4.3-4.3-4.3-4.3 1.4-1.4 5 5a1 1 0 0 1 0 1.4l-5 5Z"/></svg> |
| 37 | 48 | Code |
| 38 | 49 | </a> |
| 39 | - <a class="<%= tab === 'commits' || commit ? 'active' : '' %>" href="/s/<%= share.id %>/repositories/<%= repository.id %>?tab=commits" <%- tab === "commits" || commit ? 'aria-current="page"' : "" %>> | |
| 50 | + <a class="<%= tab === 'issues' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'issues' }) %>" <%- tab === "issues" ? 'aria-current="page"' : "" %>> | |
| 51 | + <svg class="repo-icon repo-icon-tab" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-1 3h2v7h-2V7Zm0 9h2v2h-2v-2Z"/></svg> | |
| 52 | + Issues | |
| 53 | + <span class="tab-count"><%= repository.issues.filter((item) => item.state === "open").length %></span> | |
| 54 | + </a> | |
| 55 | + <a class="<%= tab === 'pulls' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'pulls' }) %>" <%- tab === "pulls" ? 'aria-current="page"' : "" %>> | |
| 56 | + <svg class="repo-icon repo-icon-tab" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3a3 3 0 1 1-1 5.83v6.34A3 3 0 1 1 7 15V8.83A3 3 0 0 1 6 3Zm0 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm0 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm12-2a3 3 0 1 1-2 2.83V15a4 4 0 0 0-4-4h-1V8l-4 4 4 4v-3h1a2 2 0 0 1 2 2v.17A3 3 0 0 1 18 15Zm0 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg> | |
| 57 | + Pull requests | |
| 58 | + <span class="tab-count"><%= repository.pullRequests.filter((item) => item.state === "open").length %></span> | |
| 59 | + </a> | |
| 60 | + <a class="<%= tab === 'releases' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'releases' }) %>" <%- tab === "releases" ? 'aria-current="page"' : "" %>> | |
| 61 | + <svg class="repo-icon repo-icon-tab" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4a1 1 0 0 1 1-1h7.59a1 1 0 0 1 .7.29l8.42 8.42a1 1 0 0 1 0 1.41l-7.59 7.59a1 1 0 0 1-1.41 0L3.29 12.3A1 1 0 0 1 3 11.59V4Zm2 1v6.17l7.41 7.42 6.18-6.18L11.17 5H5Zm3 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z"/></svg> | |
| 62 | + Releases | |
| 63 | + <span class="tab-count"><%= repository.releases.length %></span> | |
| 64 | + </a> | |
| 65 | + <a class="<%= tab === 'commits' || commit ? 'active' : '' %>" href="<%= repoHref({ tab: 'commits' }) %>" <%- tab === "commits" || commit ? 'aria-current="page"' : "" %>> | |
| 40 | 66 | <svg class="repo-icon repo-icon-tab" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7a5 5 0 1 1-4.9 6H2v-2h5.1A5 5 0 0 1 12 7Zm0 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm4.9 2H22v2h-5.1v-2Z"/></svg> |
| 41 | 67 | Commits |
| 42 | 68 | <span class="tab-count"><%= repository.commits.length %></span> |
| @@ -47,7 +73,7 @@ | ||
| 47 | 73 | <main class="repository-shell"> |
| 48 | 74 | <% if (commit) { %> |
| 49 | 75 | <nav class="repo-breadcrumbs" aria-label="Commit navigation"> |
| 50 | - <a href="/s/<%= share.id %>/repositories/<%= repository.id %>?tab=commits">Commits</a> | |
| 76 | + <a href="<%= repoHref({ tab: 'commits' }) %>">Commits</a> | |
| 51 | 77 | <span>/</span> |
| 52 | 78 | <span><%= commit.sha.slice(0, 7) %></span> |
| 53 | 79 | </nav> |
| @@ -81,13 +107,235 @@ | ||
| 81 | 107 | <p class="empty-state">No changed files are available for this commit.</p> |
| 82 | 108 | <% } %> |
| 83 | 109 | </section> |
| 110 | + <% } else if (issue) { %> | |
| 111 | + <nav class="repo-breadcrumbs" aria-label="Issue navigation"> | |
| 112 | + <a href="<%= globalRepoHref({ tab: 'issues' }) %>">Issues</a> | |
| 113 | + <span>/</span> | |
| 114 | + <span>#<%= issue.number %></span> | |
| 115 | + </nav> | |
| 116 | + <section class="discussion-page"> | |
| 117 | + <header class="work-detail-heading"> | |
| 118 | + <div> | |
| 119 | + <h2><%= issue.title %> <span>#<%= issue.number %></span></h2> | |
| 120 | + <p> | |
| 121 | + <span class="state-badge <%= issue.state %>"><%= issue.state %></span> | |
| 122 | + <strong><%= issue.author.login %></strong> opened this issue on <%= formatDate(issue.createdAt) %> | |
| 123 | + · <%= issue.comments.length %> <%= issue.comments.length === 1 ? "comment" : "comments" %> | |
| 124 | + </p> | |
| 125 | + </div> | |
| 126 | + <span class="read-only-badge">Read only</span> | |
| 127 | + </header> | |
| 128 | + <div class="discussion-layout"> | |
| 129 | + <div class="discussion-timeline"> | |
| 130 | + <article class="discussion-card"> | |
| 131 | + <header> | |
| 132 | + <strong><%= issue.author.login %></strong> | |
| 133 | + <span>opened on <%= formatDate(issue.createdAt) %></span> | |
| 134 | + </header> | |
| 135 | + <div class="discussion-body"> | |
| 136 | + <% if (issue.body) { %><%- renderMarkdown(issue.body) %><% } else { %><p>No description was provided.</p><% } %> | |
| 137 | + </div> | |
| 138 | + </article> | |
| 139 | + <% issue.comments.forEach((comment) => { %> | |
| 140 | + <article class="discussion-card"> | |
| 141 | + <header> | |
| 142 | + <strong><%= comment.author.login %></strong> | |
| 143 | + <span>commented on <%= formatDate(comment.createdAt) %></span> | |
| 144 | + </header> | |
| 145 | + <div class="discussion-body"> | |
| 146 | + <% if (comment.body) { %><%- renderMarkdown(comment.body) %><% } else { %><p>No comment text was included.</p><% } %> | |
| 147 | + </div> | |
| 148 | + </article> | |
| 149 | + <% }) %> | |
| 150 | + </div> | |
| 151 | + <aside class="work-sidebar"> | |
| 152 | + <section> | |
| 153 | + <strong>Labels</strong> | |
| 154 | + <div class="work-labels"> | |
| 155 | + <% issue.labels.forEach((label) => { %><span class="work-label"><%= label.name %></span><% }) %> | |
| 156 | + <% if (!issue.labels.length) { %><span>None</span><% } %> | |
| 157 | + </div> | |
| 158 | + </section> | |
| 159 | + <section> | |
| 160 | + <strong>Status</strong> | |
| 161 | + <span><%= issue.stateReason || issue.state %></span> | |
| 162 | + </section> | |
| 163 | + </aside> | |
| 164 | + </div> | |
| 165 | + </section> | |
| 166 | + <% } else if (pullRequest) { %> | |
| 167 | + <nav class="repo-breadcrumbs" aria-label="Pull request navigation"> | |
| 168 | + <a href="<%= globalRepoHref({ tab: 'pulls' }) %>">Pull requests</a> | |
| 169 | + <span>/</span> | |
| 170 | + <span>#<%= pullRequest.number %></span> | |
| 171 | + </nav> | |
| 172 | + <section class="discussion-page"> | |
| 173 | + <header class="work-detail-heading"> | |
| 174 | + <div> | |
| 175 | + <h2><%= pullRequest.title %> <span>#<%= pullRequest.number %></span></h2> | |
| 176 | + <p> | |
| 177 | + <% const pullState = pullRequest.draft ? "draft" : pullRequest.merged ? "merged" : pullRequest.state; %> | |
| 178 | + <span class="state-badge <%= pullState %>"><%= pullState %></span> | |
| 179 | + <strong><%= pullRequest.author.login %></strong> opened this pull request on <%= formatDate(pullRequest.createdAt) %> | |
| 180 | + </p> | |
| 181 | + </div> | |
| 182 | + <span class="read-only-badge">Read only</span> | |
| 183 | + </header> | |
| 184 | + <div class="pull-summary"> | |
| 185 | + <span><code><%= pullRequest.head %></code> into <code><%= pullRequest.base %></code></span> | |
| 186 | + <span><strong><%= pullRequest.commitCount %></strong> commits</span> | |
| 187 | + <span><strong><%= pullRequest.changedFiles %></strong> changed files</span> | |
| 188 | + <span class="commit-change"><b>+<%= pullRequest.additions %></b> <i>−<%= pullRequest.deletions %></i></span> | |
| 189 | + </div> | |
| 190 | + <div class="discussion-layout"> | |
| 191 | + <div class="discussion-timeline"> | |
| 192 | + <article class="discussion-card"> | |
| 193 | + <header> | |
| 194 | + <strong><%= pullRequest.author.login %></strong> | |
| 195 | + <span>opened on <%= formatDate(pullRequest.createdAt) %></span> | |
| 196 | + </header> | |
| 197 | + <div class="discussion-body"> | |
| 198 | + <% if (pullRequest.body) { %><%- renderMarkdown(pullRequest.body) %><% } else { %><p>No description was provided.</p><% } %> | |
| 199 | + </div> | |
| 200 | + </article> | |
| 201 | + <% pullRequest.conversation.forEach((comment) => { %> | |
| 202 | + <article class="discussion-card"> | |
| 203 | + <header> | |
| 204 | + <strong><%= comment.author.login %></strong> | |
| 205 | + <span> | |
| 206 | + <% if (comment.type === "review") { %> | |
| 207 | + submitted a <%= (comment.state || "review").toLocaleLowerCase("en") %> review on <%= formatDate(comment.createdAt) %> | |
| 208 | + <% } else if (comment.type === "review-comment") { %> | |
| 209 | + reviewed <%= comment.path || "a changed file" %><%= comment.line ? " at line " + comment.line : "" %> on <%= formatDate(comment.createdAt) %> | |
| 210 | + <% } else { %> | |
| 211 | + commented on <%= formatDate(comment.createdAt) %> | |
| 212 | + <% } %> | |
| 213 | + </span> | |
| 214 | + </header> | |
| 215 | + <div class="discussion-body"> | |
| 216 | + <% if (comment.body) { %><%- renderMarkdown(comment.body) %><% } else { %><p>No review text was included.</p><% } %> | |
| 217 | + </div> | |
| 218 | + </article> | |
| 219 | + <% }) %> | |
| 220 | + <section class="pull-files"> | |
| 221 | + <h3>Files changed <span><%= pullRequest.files.length %></span></h3> | |
| 222 | + <% pullRequest.files.forEach((changed) => { %> | |
| 223 | + <section class="diff-file"> | |
| 224 | + <header> | |
| 225 | + <strong><%= changed.filename %></strong> | |
| 226 | + <span><%= changed.status %> · +<%= changed.additions %> −<%= changed.deletions %></span> | |
| 227 | + </header> | |
| 228 | + <% if (!changed.patch) { %> | |
| 229 | + <p class="empty-state">Line changes are not available for this file.</p> | |
| 230 | + <% } else { %> | |
| 231 | + <pre><% changed.patch.split("\n").forEach((line) => { let kind = line.startsWith("+") && !line.startsWith("+++") ? "addition" : line.startsWith("-") && !line.startsWith("---") ? "deletion" : ""; %><span class="<%= kind %>"><%= line %></span> | |
| 232 | +<% }) %></pre> | |
| 233 | + <% } %> | |
| 234 | + </section> | |
| 235 | + <% }) %> | |
| 236 | + <% if (!pullRequest.files.length) { %><p class="empty-state">No changed files are included in this snapshot.</p><% } %> | |
| 237 | + </section> | |
| 238 | + </div> | |
| 239 | + <aside class="work-sidebar"> | |
| 240 | + <section> | |
| 241 | + <strong>Labels</strong> | |
| 242 | + <div class="work-labels"> | |
| 243 | + <% pullRequest.labels.forEach((label) => { %><span class="work-label"><%= label.name %></span><% }) %> | |
| 244 | + <% if (!pullRequest.labels.length) { %><span>None</span><% } %> | |
| 245 | + </div> | |
| 246 | + </section> | |
| 247 | + <section> | |
| 248 | + <strong>Merge status</strong> | |
| 249 | + <span><%= pullRequest.merged ? "Merged" : pullRequest.draft ? "Draft" : pullRequest.mergeableState || pullRequest.state %></span> | |
| 250 | + </section> | |
| 251 | + </aside> | |
| 252 | + </div> | |
| 253 | + </section> | |
| 254 | + <% } else if (release) { %> | |
| 255 | + <nav class="repo-breadcrumbs" aria-label="Release navigation"> | |
| 256 | + <a href="<%= globalRepoHref({ tab: 'releases' }) %>">Releases</a> | |
| 257 | + <span>/</span> | |
| 258 | + <span><%= release.tagName %></span> | |
| 259 | + </nav> | |
| 260 | + <article class="release-detail"> | |
| 261 | + <header> | |
| 262 | + <div> | |
| 263 | + <div class="release-kicker"> | |
| 264 | + <span class="release-tag"><%= release.tagName %></span> | |
| 265 | + <% if (release.draft) { %><span class="release-status">Draft</span><% } %> | |
| 266 | + <% if (release.prerelease) { %><span class="release-status">Pre-release</span><% } %> | |
| 267 | + </div> | |
| 268 | + <h2><%= release.name %></h2> | |
| 269 | + <p> | |
| 270 | + <strong><%= release.author.login %></strong> | |
| 271 | + <%= release.publishedAt ? "published" : "created" %> this release on | |
| 272 | + <%= formatDate(release.publishedAt || release.createdAt) %> | |
| 273 | + </p> | |
| 274 | + </div> | |
| 275 | + <% const releaseTag = references.tags.find((tag) => tag.name === release.tagName); %> | |
| 276 | + <% if (releaseTag) { %><a class="quiet-button" href="<%= refHref({ ...releaseTag, type: 'tag' }) %>">Browse tag</a><% } %> | |
| 277 | + </header> | |
| 278 | + <div class="release-body discussion-body"> | |
| 279 | + <% if (release.body) { %><%- renderMarkdown(release.body) %><% } else { %><p>No release notes were provided.</p><% } %> | |
| 280 | + </div> | |
| 281 | + <section class="release-assets"> | |
| 282 | + <h3>Assets <span><%= release.assets.length %></span></h3> | |
| 283 | + <% if (release.assets.length) { %> | |
| 284 | + <ul> | |
| 285 | + <% release.assets.forEach((asset) => { %> | |
| 286 | + <li> | |
| 287 | + <strong><%= asset.label || asset.name %></strong> | |
| 288 | + <% if (asset.label) { %><code><%= asset.name %></code><% } %> | |
| 289 | + <span><%= asset.size.toLocaleString() %> bytes · <%= asset.downloadCount.toLocaleString() %> GitHub downloads</span> | |
| 290 | + </li> | |
| 291 | + <% }) %> | |
| 292 | + </ul> | |
| 293 | + <p>Assets are listed for reference and are not downloadable from this read-only snapshot.</p> | |
| 294 | + <% } else { %> | |
| 295 | + <p>No release assets are included.</p> | |
| 296 | + <% } %> | |
| 297 | + </section> | |
| 298 | + </article> | |
| 299 | + <% } else if (historyFile) { %> | |
| 300 | + <nav class="repo-breadcrumbs" aria-label="File history navigation"> | |
| 301 | + <a href="<%= repoHref() %>"><%= repository.name %></a> | |
| 302 | + <span>/</span> | |
| 303 | + <a href="<%= repoHref({ file: historyFile.path }) %>"><%= historyFile.path %></a> | |
| 304 | + <span>/</span> | |
| 305 | + <span>History</span> | |
| 306 | + </nav> | |
| 307 | + <section class="commits-page file-history-page"> | |
| 308 | + <header class="section-title-row"> | |
| 309 | + <div> | |
| 310 | + <p class="section-kicker">File history</p> | |
| 311 | + <h2>History for <code><%= historyFile.path %></code></h2> | |
| 312 | + </div> | |
| 313 | + <a class="quiet-button" href="<%= repoHref({ file: historyFile.path }) %>">View file</a> | |
| 314 | + </header> | |
| 315 | + <ol class="commit-feed"> | |
| 316 | + <% fileHistory.forEach((item) => { %> | |
| 317 | + <li> | |
| 318 | + <span class="commit-avatar" aria-hidden="true"><%= item.author.slice(0, 1).toUpperCase() %></span> | |
| 319 | + <a class="commit-main" href="<%= repoHref({ commit: item.sha }) %>"> | |
| 320 | + <strong><%= item.message.split("\n")[0] %></strong> | |
| 321 | + <span><%= item.author %> committed <%= formatDate(item.date) %></span> | |
| 322 | + </a> | |
| 323 | + <span class="commit-change"><b>+<%= item.fileChange.additions %></b> <i>−<%= item.fileChange.deletions %></i></span> | |
| 324 | + <code><%= item.sha.slice(0, 7) %></code> | |
| 325 | + </li> | |
| 326 | + <% }) %> | |
| 327 | + <% if (!fileHistory.length) { %> | |
| 328 | + <li class="empty-state">No file history is included in this snapshot.</li> | |
| 329 | + <% } %> | |
| 330 | + </ol> | |
| 331 | + </section> | |
| 84 | 332 | <% } else if (file) { %> |
| 85 | 333 | <nav class="repo-breadcrumbs" aria-label="File path"> |
| 86 | - <a href="/s/<%= share.id %>/repositories/<%= repository.id %>"><%= repository.name %></a> | |
| 334 | + <a href="<%= repoHref() %>"><%= repository.name %></a> | |
| 87 | 335 | <% let filePath = ""; file.path.split("/").forEach((part, index, parts) => { filePath += (filePath ? "/" : "") + part; %> |
| 88 | 336 | <span>/</span> |
| 89 | 337 | <% if (index < parts.length - 1) { %> |
| 90 | - <a href="?path=<%= encodeURIComponent(filePath) %>"><%= part %></a> | |
| 338 | + <a href="<%= repoHref({ path: filePath }) %>"><%= part %></a> | |
| 91 | 339 | <% } else { %> |
| 92 | 340 | <span><%= part %></span> |
| 93 | 341 | <% } %> |
| @@ -99,28 +347,60 @@ | ||
| 99 | 347 | <strong><%= file.path.split("/").pop() %></strong> |
| 100 | 348 | <span><%= file.size.toLocaleString() %> bytes</span> |
| 101 | 349 | </div> |
| 102 | - <a href="?path=<%= encodeURIComponent(file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : '') %>">← Back to files</a> | |
| 350 | + <div class="file-view-actions"> | |
| 351 | + <button class="quiet-button copy-path" type="button" data-copy-text="<%= file.path %>">Copy path</button> | |
| 352 | + <a class="quiet-button" href="<%= repoHref({ history: file.path }) %>">History</a> | |
| 353 | + <a href="<%= repoHref({ path: file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : '' }) %>">← Back to files</a> | |
| 354 | + </div> | |
| 103 | 355 | </header> |
| 104 | - <% if (file.content === null) { %> | |
| 356 | + <% if (markdownFile) { %> | |
| 357 | + <nav class="file-view-tabs" aria-label="File view"> | |
| 358 | + <a class="<%= fileView === 'preview' ? 'active' : '' %>" href="<%= repoHref({ file: file.path }) %>"<% if (fileView === "preview") { %> aria-current="page"<% } %>>Preview</a> | |
| 359 | + <a class="<%= fileView === 'source' ? 'active' : '' %>" href="<%= repoHref({ file: file.path, view: 'source' }) %>"<% if (fileView === "source") { %> aria-current="page"<% } %>>Source</a> | |
| 360 | + </nav> | |
| 361 | + <% } %> | |
| 362 | + <% if (markdownFile && fileView === "preview" && file.content !== null) { %> | |
| 363 | + <article class="markdown-file-preview markdown-body"><%- renderMarkdown(file.content, { | |
| 364 | + baseUrl: "/s/" + share.id + "/repositories/" + repository.id, | |
| 365 | + readmePath: file.path, | |
| 366 | + files: repository.files, | |
| 367 | + repositoryUrl: repoHref | |
| 368 | + }) %></article> | |
| 369 | + <% } else if (file.binaryContent && file.mediaType) { %> | |
| 370 | + <div class="image-file-preview"> | |
| 371 | + <img src="data:<%= file.mediaType %>;base64,<%= file.binaryContent %>" alt="<%= file.path.split('/').pop() %>"> | |
| 372 | + </div> | |
| 373 | + <% } else if (file.content === null) { %> | |
| 105 | 374 | <div class="empty-state">This file cannot be displayed as text<%= file.truncated ? " because it is larger than the snapshot preview limit." : "." %></div> |
| 106 | 375 | <% } else { %> |
| 107 | - <pre class="code-view"><code><%= file.content %></code></pre> | |
| 376 | + <div class="code-view"> | |
| 377 | + <table class="code-table" aria-label="<%= file.path %> source"> | |
| 378 | + <tbody> | |
| 379 | + <% file.content.split(/\r?\n/).forEach((line, index) => { const lineNumber = index + 1; %> | |
| 380 | + <tr id="L<%= lineNumber %>"> | |
| 381 | + <th scope="row"><a href="#L<%= lineNumber %>" aria-label="Line <%= lineNumber %>"><%= lineNumber %></a></th> | |
| 382 | + <td><code><%= line %></code></td> | |
| 383 | + </tr> | |
| 384 | + <% }) %> | |
| 385 | + </tbody> | |
| 386 | + </table> | |
| 387 | + </div> | |
| 108 | 388 | <% } %> |
| 109 | 389 | </section> |
| 110 | 390 | <% } else if (tab === "commits") { %> |
| 111 | 391 | <section class="commits-page"> |
| 112 | 392 | <header class="section-title-row"> |
| 113 | 393 | <div> |
| 114 | - <p class="section-kicker"><%= repository.defaultBranch || "Repository" %> history</p> | |
| 394 | + <p class="section-kicker"><%= selectedReference.name %> history</p> | |
| 115 | 395 | <h2><%= repository.commits.length %> <%= repository.commits.length === 1 ? "commit" : "commits" %></h2> |
| 116 | 396 | </div> |
| 117 | - <a class="quiet-button" href="/s/<%= share.id %>/repositories/<%= repository.id %>">Browse code</a> | |
| 397 | + <a class="quiet-button" href="<%= repoHref() %>">Browse code</a> | |
| 118 | 398 | </header> |
| 119 | 399 | <ol class="commit-feed"> |
| 120 | 400 | <% repository.commits.forEach((item) => { %> |
| 121 | 401 | <li> |
| 122 | 402 | <span class="commit-avatar" aria-hidden="true"><%= item.author.slice(0, 1).toUpperCase() %></span> |
| 123 | - <a class="commit-main" href="?commit=<%= item.sha %>"> | |
| 403 | + <a class="commit-main" href="<%= repoHref({ commit: item.sha }) %>"> | |
| 124 | 404 | <strong><%= item.message.split("\n")[0] %></strong> |
| 125 | 405 | <span><%= item.author %> committed <%= formatDate(item.date) %></span> |
| 126 | 406 | </a> |
| @@ -133,26 +413,160 @@ | ||
| 133 | 413 | <% } %> |
| 134 | 414 | </ol> |
| 135 | 415 | </section> |
| 416 | + <% } else if (tab === "issues") { %> | |
| 417 | + <section class="work-list-page"> | |
| 418 | + <header class="work-list-header"> | |
| 419 | + <div> | |
| 420 | + <h2>Issues</h2> | |
| 421 | + <p>Questions, bugs, and planned work captured with this snapshot.</p> | |
| 422 | + </div> | |
| 423 | + <nav aria-label="Issue state"> | |
| 424 | + <a class="<%= stateFilter === 'all' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'issues' }) %>">All <span><%= repository.issues.length %></span></a> | |
| 425 | + <a class="<%= stateFilter === 'open' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'issues', state: 'open' }) %>">Open <span><%= repository.issues.filter((item) => item.state === "open").length %></span></a> | |
| 426 | + <a class="<%= stateFilter === 'closed' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'issues', state: 'closed' }) %>">Closed <span><%= repository.issues.filter((item) => item.state === "closed").length %></span></a> | |
| 427 | + </nav> | |
| 428 | + </header> | |
| 429 | + <div class="work-list"> | |
| 430 | + <% visibleIssues.forEach((item) => { %> | |
| 431 | + <article> | |
| 432 | + <span class="work-state-icon <%= item.state %>" aria-label="<%= item.state %> issue"></span> | |
| 433 | + <div> | |
| 434 | + <a class="work-title" href="<%= globalRepoHref({ issue: item.number }) %>"><%= item.title %></a> | |
| 435 | + <div class="work-labels"> | |
| 436 | + <% item.labels.forEach((label) => { %><span class="work-label"><%= label.name %></span><% }) %> | |
| 437 | + </div> | |
| 438 | + <p>#<%= item.number %> opened on <%= formatDate(item.createdAt) %> by <%= item.author.login %></p> | |
| 439 | + </div> | |
| 440 | + <span class="work-comments"><strong><%= item.comments.length %></strong> comments</span> | |
| 441 | + </article> | |
| 442 | + <% }) %> | |
| 443 | + <% if (!visibleIssues.length) { %> | |
| 444 | + <div class="empty-state">No <%= stateFilter === "all" ? "" : stateFilter + " " %>issues are included in this snapshot.</div> | |
| 445 | + <% } %> | |
| 446 | + </div> | |
| 447 | + </section> | |
| 448 | + <% } else if (tab === "pulls") { %> | |
| 449 | + <section class="work-list-page"> | |
| 450 | + <header class="work-list-header"> | |
| 451 | + <div> | |
| 452 | + <h2>Pull requests</h2> | |
| 453 | + <p>Proposed changes, reviews, and file diffs captured with this snapshot.</p> | |
| 454 | + </div> | |
| 455 | + <nav aria-label="Pull request state"> | |
| 456 | + <a class="<%= stateFilter === 'all' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'pulls' }) %>">All <span><%= repository.pullRequests.length %></span></a> | |
| 457 | + <a class="<%= stateFilter === 'open' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'pulls', state: 'open' }) %>">Open <span><%= repository.pullRequests.filter((item) => item.state === "open").length %></span></a> | |
| 458 | + <a class="<%= stateFilter === 'closed' ? 'active' : '' %>" href="<%= globalRepoHref({ tab: 'pulls', state: 'closed' }) %>">Closed <span><%= repository.pullRequests.filter((item) => item.state === "closed").length %></span></a> | |
| 459 | + </nav> | |
| 460 | + </header> | |
| 461 | + <div class="work-list"> | |
| 462 | + <% visiblePullRequests.forEach((item) => { const itemState = item.draft ? "draft" : item.merged ? "merged" : item.state; %> | |
| 463 | + <article> | |
| 464 | + <span class="work-state-icon <%= itemState %>" aria-label="<%= itemState %> pull request"></span> | |
| 465 | + <div> | |
| 466 | + <a class="work-title" href="<%= globalRepoHref({ pull: item.number }) %>"><%= item.title %></a> | |
| 467 | + <div class="work-labels"> | |
| 468 | + <% item.labels.forEach((label) => { %><span class="work-label"><%= label.name %></span><% }) %> | |
| 469 | + </div> | |
| 470 | + <p>#<%= item.number %> opened on <%= formatDate(item.createdAt) %> by <%= item.author.login %> · <%= item.head %> into <%= item.base %></p> | |
| 471 | + </div> | |
| 472 | + <span class="work-comments"><strong><%= item.conversation.length %></strong> updates</span> | |
| 473 | + </article> | |
| 474 | + <% }) %> | |
| 475 | + <% if (!visiblePullRequests.length) { %> | |
| 476 | + <div class="empty-state">No <%= stateFilter === "all" ? "" : stateFilter + " " %>pull requests are included in this snapshot.</div> | |
| 477 | + <% } %> | |
| 478 | + </div> | |
| 479 | + </section> | |
| 480 | + <% } else if (tab === "releases") { %> | |
| 481 | + <section class="release-list-page"> | |
| 482 | + <header> | |
| 483 | + <div> | |
| 484 | + <h2>Releases</h2> | |
| 485 | + <p>Published versions and release notes captured with this snapshot.</p> | |
| 486 | + </div> | |
| 487 | + <span><strong><%= repository.releases.length %></strong> total</span> | |
| 488 | + </header> | |
| 489 | + <div class="release-list"> | |
| 490 | + <% repository.releases.forEach((item, index) => { %> | |
| 491 | + <article> | |
| 492 | + <aside> | |
| 493 | + <span class="release-tag"><%= item.tagName %></span> | |
| 494 | + <time><%= formatDate(item.publishedAt || item.createdAt) %></time> | |
| 495 | + </aside> | |
| 496 | + <div> | |
| 497 | + <div class="release-list-title"> | |
| 498 | + <a href="<%= globalRepoHref({ release: item.id }) %>"><%= item.name %></a> | |
| 499 | + <% if (index === 0 && !item.draft && !item.prerelease) { %><span class="latest-release">Latest</span><% } %> | |
| 500 | + <% if (item.draft) { %><span class="release-status">Draft</span><% } %> | |
| 501 | + <% if (item.prerelease) { %><span class="release-status">Pre-release</span><% } %> | |
| 502 | + </div> | |
| 503 | + <p><%= item.body ? item.body.replace(/[#*_`>-]/g, "").trim().slice(0, 260) : "No release notes were provided." %></p> | |
| 504 | + <span>Published by <strong><%= item.author.login %></strong> · <%= item.assets.length %> <%= item.assets.length === 1 ? "asset" : "assets" %></span> | |
| 505 | + </div> | |
| 506 | + </article> | |
| 507 | + <% }) %> | |
| 508 | + <% if (!repository.releases.length) { %><div class="empty-state">No releases are included in this snapshot.</div><% } %> | |
| 509 | + </div> | |
| 510 | + </section> | |
| 136 | 511 | <% } else { %> |
| 137 | 512 | <section class="code-overview"> |
| 138 | 513 | <div class="code-toolbar"> |
| 139 | - <span class="branch-pill"> | |
| 140 | - <svg class="repo-icon repo-icon-branch" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3a3 3 0 1 1-1 5.83v6.34A3.001 3.001 0 1 1 8 18v-3h4a4 4 0 0 0 4-4V8.83a3.001 3.001 0 1 1 2 0V11a6 6 0 0 1-6 6H8v1a3 3 0 0 1-2-2.83V8.83A3.001 3.001 0 0 1 7 3Z"/></svg> | |
| 141 | - <%= repository.defaultBranch || "No branch" %> | |
| 142 | - </span> | |
| 514 | + <% if (references.branches.length + references.tags.length > 1) { %> | |
| 515 | + <details class="ref-switcher"> | |
| 516 | + <summary class="branch-pill"> | |
| 517 | + <svg class="repo-icon repo-icon-branch" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3a3 3 0 1 1-1 5.83v6.34A3.001 3.001 0 1 1 8 18v-3h4a4 4 0 0 0 4-4V8.83a3.001 3.001 0 1 1 2 0V11a6 6 0 0 1-6 6H8v1a3 3 0 0 1-2-2.83V8.83A3.001 3.001 0 0 1 7 3Z"/></svg> | |
| 518 | + <span><%= selectedReference.name %></span> | |
| 519 | + <svg class="ref-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m2.5 4 3.5 4 3.5-4Z"/></svg> | |
| 520 | + </summary> | |
| 521 | + <div class="ref-menu"> | |
| 522 | + <header> | |
| 523 | + <strong>Switch branches or tags</strong> | |
| 524 | + <span><%= references.branches.length %> <%= references.branches.length === 1 ? "branch" : "branches" %>, <%= references.tags.length %> <%= references.tags.length === 1 ? "tag" : "tags" %></span> | |
| 525 | + </header> | |
| 526 | + <label class="sr-only" for="ref-filter">Find a branch or tag</label> | |
| 527 | + <input id="ref-filter" data-ref-filter type="search" placeholder="Find a branch or tag"> | |
| 528 | + <div class="ref-options"> | |
| 529 | + <% if (references.branches.length) { %><p>Branches</p><% } %> | |
| 530 | + <% references.branches.forEach((branch) => { const isSelected = selectedReference.type === "branch" && selectedReference.name === branch.name; %> | |
| 531 | + <a class="<%= isSelected ? 'selected' : '' %>" data-ref-option data-ref-name="<%= branch.name.toLocaleLowerCase('en') %>" href="<%= refHref({ ...branch, type: 'branch' }) %>"> | |
| 532 | + <span class="ref-check" aria-hidden="true"></span> | |
| 533 | + <span><%= branch.name %></span> | |
| 534 | + <% if (branch.name === repository.defaultBranch) { %><small>default</small><% } %> | |
| 535 | + </a> | |
| 536 | + <% }) %> | |
| 537 | + <% if (references.tags.length) { %><p>Tags</p><% } %> | |
| 538 | + <% references.tags.forEach((tag) => { const isSelected = selectedReference.type === "tag" && selectedReference.name === tag.name; %> | |
| 539 | + <a class="<%= isSelected ? 'selected' : '' %>" data-ref-option data-ref-name="<%= tag.name.toLocaleLowerCase('en') %>" href="<%= refHref({ ...tag, type: 'tag' }) %>"> | |
| 540 | + <span class="ref-check" aria-hidden="true"></span> | |
| 541 | + <span><%= tag.name %></span> | |
| 542 | + <small>tag</small> | |
| 543 | + </a> | |
| 544 | + <% }) %> | |
| 545 | + <p class="ref-no-results" data-ref-empty hidden>No branches or tags match.</p> | |
| 546 | + </div> | |
| 547 | + </div> | |
| 548 | + </details> | |
| 549 | + <% } else { %> | |
| 550 | + <span class="branch-pill"> | |
| 551 | + <svg class="repo-icon repo-icon-branch" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3a3 3 0 1 1-1 5.83v6.34A3.001 3.001 0 1 1 8 18v-3h4a4 4 0 0 0 4-4V8.83a3.001 3.001 0 1 1 2 0V11a6 6 0 0 1-6 6H8v1a3 3 0 0 1-2-2.83V8.83A3.001 3.001 0 1 1 7 3Z"/></svg> | |
| 552 | + <%= selectedReference.name %> | |
| 553 | + </span> | |
| 554 | + <% } %> | |
| 143 | 555 | <% if (repository.headSha) { %> |
| 144 | 556 | <span class="head-reference">Snapshot head <code><%= repository.headSha.slice(0, 7) %></code></span> |
| 145 | 557 | <% } %> |
| 146 | - <a class="commit-shortcut" href="?tab=commits"> | |
| 558 | + <a class="commit-shortcut" href="<%= repoHref({ tab: 'commits' }) %>"> | |
| 147 | 559 | <svg class="repo-icon repo-icon-branch" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7a5 5 0 1 1-4.9 6H2v-2h5.1A5 5 0 0 1 12 7Zm0 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm4.9 2H22v2h-5.1v-2Z"/></svg> |
| 148 | 560 | <strong><%= repository.commits.length %></strong> commits |
| 149 | 561 | </a> |
| 150 | 562 | </div> |
| 151 | 563 | |
| 152 | - <section class="browser-card"> | |
| 564 | + <div class="<%= path ? 'repository-code-only' : 'repository-overview-layout' %>"> | |
| 565 | + <div class="repository-code-column"> | |
| 566 | + <section class="browser-card"> | |
| 153 | 567 | <% const latestCommit = repository.commits[0]; %> |
| 154 | 568 | <% if (latestCommit) { %> |
| 155 | - <a class="latest-commit" href="?commit=<%= latestCommit.sha %>"> | |
| 569 | + <a class="latest-commit" href="<%= repoHref({ commit: latestCommit.sha }) %>"> | |
| 156 | 570 | <span class="commit-avatar" aria-hidden="true"><%= latestCommit.author.slice(0, 1).toUpperCase() %></span> |
| 157 | 571 | <strong><%= latestCommit.author %></strong> |
| 158 | 572 | <span class="latest-message"><%= latestCommit.message.split("\n")[0] %></span> |
| @@ -161,28 +575,28 @@ | ||
| 161 | 575 | </a> |
| 162 | 576 | <% } %> |
| 163 | 577 | <nav class="path-bar" aria-label="File path"> |
| 164 | - <a href="/s/<%= share.id %>/repositories/<%= repository.id %>"><%= repository.name %></a> | |
| 578 | + <a href="<%= repoHref() %>"><%= repository.name %></a> | |
| 165 | 579 | <% let builtPath = ""; path.split("/").filter(Boolean).forEach((part) => { builtPath += (builtPath ? "/" : "") + part; %> |
| 166 | - <span>/</span><a href="?path=<%= encodeURIComponent(builtPath) %>"><%= part %></a> | |
| 580 | + <span>/</span><a href="<%= repoHref({ path: builtPath }) %>"><%= part %></a> | |
| 167 | 581 | <% }) %> |
| 168 | 582 | </nav> |
| 169 | 583 | <div class="file-table" role="table" aria-label="Repository files"> |
| 170 | 584 | <% if (path) { const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; %> |
| 171 | - <a class="file-row" href="?path=<%= encodeURIComponent(parent) %>"> | |
| 585 | + <a class="file-row" href="<%= repoHref({ path: parent }) %>"> | |
| 172 | 586 | <span class="file-symbol back-symbol" aria-hidden="true">←</span> |
| 173 | 587 | <strong>..</strong> |
| 174 | 588 | <span>Parent folder</span> |
| 175 | 589 | </a> |
| 176 | 590 | <% } %> |
| 177 | 591 | <% browser.folders.forEach((folder) => { const nextPath = path ? path + "/" + folder : folder; %> |
| 178 | - <a class="file-row" href="?path=<%= encodeURIComponent(nextPath) %>"> | |
| 592 | + <a class="file-row" href="<%= repoHref({ path: nextPath }) %>"> | |
| 179 | 593 | <span class="file-symbol" aria-hidden="true"><svg class="repo-icon repo-icon-file" width="16" height="16" viewBox="0 0 24 24"><path d="M3 5.75C3 4.78 3.78 4 4.75 4h5.08c.46 0 .9.18 1.23.51L12.55 6H19.25C20.22 6 21 6.78 21 7.75v9.5c0 .97-.78 1.75-1.75 1.75H4.75C3.78 19 3 18.22 3 17.25V5.75Z"/></svg></span> |
| 180 | 594 | <strong><%= folder %></strong> |
| 181 | 595 | <span>Folder</span> |
| 182 | 596 | </a> |
| 183 | 597 | <% }) %> |
| 184 | 598 | <% browser.files.forEach((item) => { %> |
| 185 | - <a class="file-row" href="?path=<%= encodeURIComponent(path) %>&file=<%= encodeURIComponent(item.path) %>"> | |
| 599 | + <a class="file-row" href="<%= repoHref({ path, file: item.path }) %>"> | |
| 186 | 600 | <span class="file-symbol file-symbol-document" aria-hidden="true"><svg class="repo-icon repo-icon-file" width="16" height="16" viewBox="0 0 24 24"><path d="M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm7 2H6v16h11V8h-4V4Z"/></svg></span> |
| 187 | 601 | <strong><%= item.path.split("/").pop() %></strong> |
| 188 | 602 | <span><%= item.size.toLocaleString() %> bytes</span> |
| @@ -192,30 +606,74 @@ | ||
| 192 | 606 | <p class="empty-state">This folder contains no files.</p> |
| 193 | 607 | <% } %> |
| 194 | 608 | </div> |
| 195 | - </section> | |
| 609 | + </section> | |
| 196 | 610 | |
| 197 | - <% if (!path && readme) { %> | |
| 198 | - <article class="readme-card"> | |
| 611 | + <% if (!path && readme) { %> | |
| 612 | + <article class="readme-card"> | |
| 199 | 613 | <header> |
| 200 | 614 | <div> |
| 201 | 615 | <svg class="repo-icon repo-icon-file" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 3h6a3 3 0 0 1 2 .76A3 3 0 0 1 14 3h6a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1h-6a2 2 0 0 0-1.45.62L12 21.2l-.55-.58A2 2 0 0 0 10 20H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm1 2v13h5c.35 0 .69.04 1 .13V6a1 1 0 0 0-1-1H5Zm9 0a1 1 0 0 0-1 1v12.13c.31-.09.65-.13 1-.13h5V5h-5Z"/></svg> |
| 202 | 616 | <strong><%= readme.path %></strong> |
| 203 | 617 | </div> |
| 204 | - <a href="?file=<%= encodeURIComponent(readme.path) %>">View source</a> | |
| 618 | + <a href="<%= repoHref({ file: readme.path, view: 'source' }) %>">View source</a> | |
| 205 | 619 | </header> |
| 206 | 620 | <div class="markdown-body"><%- renderMarkdown(readme.content, { |
| 207 | 621 | baseUrl: "/s/" + share.id + "/repositories/" + repository.id, |
| 208 | 622 | readmePath: readme.path, |
| 209 | - files: repository.files | |
| 623 | + files: repository.files, | |
| 624 | + repositoryUrl: repoHref | |
| 210 | 625 | }) %></div> |
| 211 | - </article> | |
| 212 | - <% } else if (!path) { %> | |
| 213 | - <section class="readme-empty"> | |
| 214 | - <strong>No README found</strong> | |
| 215 | - <p>Browse the repository files above to understand this project.</p> | |
| 216 | - </section> | |
| 217 | - <% } %> | |
| 626 | + </article> | |
| 627 | + <% } else if (!path) { %> | |
| 628 | + <section class="readme-empty"> | |
| 629 | + <strong>No README found</strong> | |
| 630 | + <p>Browse the repository files above to understand this project.</p> | |
| 631 | + </section> | |
| 632 | + <% } %> | |
| 633 | + </div> | |
| 634 | + <% if (!path) { %> | |
| 635 | + <aside class="repository-about"> | |
| 636 | + <h2>About</h2> | |
| 637 | + <p><%= repository.description || "No repository description." %></p> | |
| 638 | + <% if (repository.homepage) { %><span class="about-homepage"><%= repository.homepage %></span><% } %> | |
| 639 | + <% if ((repository.topics || []).length) { %> | |
| 640 | + <div class="about-topics"> | |
| 641 | + <% repository.topics.forEach((topic) => { %><span><%= topic %></span><% }) %> | |
| 642 | + </div> | |
| 643 | + <% } %> | |
| 644 | + <% if (repository.archived) { %><span class="archived-badge">Archived on GitHub</span><% } %> | |
| 645 | + <ul class="about-stats"> | |
| 646 | + <li><strong><%= repository.stargazersCount || 0 %></strong> stars</li> | |
| 647 | + <li><strong><%= repository.watchersCount || 0 %></strong> watching</li> | |
| 648 | + <li><strong><%= repository.forksCount || 0 %></strong> forks</li> | |
| 649 | + <li><strong><%= references.branches.length %></strong> branches</li> | |
| 650 | + <li><strong><%= references.tags.length %></strong> tags</li> | |
| 651 | + <li><strong><%= repository.releases.length %></strong> releases</li> | |
| 652 | + </ul> | |
| 653 | + <% if (repository.license) { %> | |
| 654 | + <section class="about-section"> | |
| 655 | + <h3>License</h3> | |
| 656 | + <p><%= repository.license.spdxId || repository.license.name %></p> | |
| 657 | + </section> | |
| 658 | + <% } %> | |
| 659 | + <% if (repository.languages.length) { %> | |
| 660 | + <section class="about-section"> | |
| 661 | + <h3>Languages</h3> | |
| 662 | + <div class="language-breakdown"> | |
| 663 | + <% repository.languages.forEach((language) => { %> | |
| 664 | + <div> | |
| 665 | + <span><%= language.name %></span> | |
| 666 | + <strong><%= language.percent.toLocaleString() %>%</strong> | |
| 667 | + </div> | |
| 668 | + <% }) %> | |
| 669 | + </div> | |
| 670 | + </section> | |
| 671 | + <% } %> | |
| 672 | + </aside> | |
| 673 | + <% } %> | |
| 674 | + </div> | |
| 218 | 675 | </section> |
| 219 | 676 | <% } %> |
| 220 | 677 | </main> |
| 678 | +<script src="/assets/app.js" defer></script> | |
| 221 | 679 | <%- include("partials/foot") %> |
added src/views/search.ejs +207 −0
| @@ -0,0 +1,207 @@ | ||
| 1 | +<%- include("partials/head", { title: query ? "Search results" : "Search", bodyClass: "viewer-page search-page" }) %> | |
| 2 | +<header class="viewer-header"> | |
| 3 | + <a class="wordmark wordmark-light" href="/s/<%= share.id %>">profile<span>Share</span></a> | |
| 4 | + <form class="snapshot-search" action="/s/<%= share.id %>/search" method="get" role="search"> | |
| 5 | + <% if (repository) { %><input type="hidden" name="repository" value="<%= repository.id %>"><% } %> | |
| 6 | + <% if (selectedReference && !selectedReference.isDefault) { %> | |
| 7 | + <input type="hidden" name="ref" value="<%= selectedReference.name %>"> | |
| 8 | + <input type="hidden" name="refType" value="<%= selectedReference.type %>"> | |
| 9 | + <% } %> | |
| 10 | + <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M10.5 3a7.5 7.5 0 1 1-4.7 13.34l-3.07 3.07-1.42-1.42 3.08-3.07A7.5 7.5 0 0 1 10.5 3Zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Z"/></svg> | |
| 11 | + <input data-search-input name="q" value="<%= query %>" aria-label="Search <%= repository ? repository.name : 'this snapshot' %>" placeholder="Search <%= repository ? repository.name : 'this snapshot' %>"> | |
| 12 | + <button type="submit">Search</button> | |
| 13 | + </form> | |
| 14 | + <nav class="top-nav" aria-label="Snapshot navigation"> | |
| 15 | + <a href="/s/<%= share.id %>">Profile</a> | |
| 16 | + <span class="meta-divider"></span> | |
| 17 | + <span>Expires <%= formatDate(share.expires_at) %></span> | |
| 18 | + </nav> | |
| 19 | +</header> | |
| 20 | + | |
| 21 | +<main class="search-shell"> | |
| 22 | + <% const referenceScope = selectedReference && !selectedReference.isDefault ? "&ref=" + encodeURIComponent(selectedReference.name) + "&refType=" + encodeURIComponent(selectedReference.type) : ""; %> | |
| 23 | + <% const scope = repository ? "&repository=" + encodeURIComponent(repository.id) + referenceScope : ""; %> | |
| 24 | + <% const visibleTotal = type === "all" ? total : results[type].length; %> | |
| 25 | + <aside class="search-filters" aria-label="Search filters"> | |
| 26 | + <h2>Filter by</h2> | |
| 27 | + <a class="<%= type === 'all' ? 'active' : '' %>" href="?q=<%= encodeURIComponent(query) %><%= scope %>"> | |
| 28 | + Everything | |
| 29 | + <span><%= total %></span> | |
| 30 | + </a> | |
| 31 | + <a class="<%= type === 'code' ? 'active' : '' %>" href="?q=<%= encodeURIComponent(query) %>&type=code<%= scope %>"> | |
| 32 | + Code | |
| 33 | + <span><%= results.code.length %></span> | |
| 34 | + </a> | |
| 35 | + <a class="<%= type === 'commits' ? 'active' : '' %>" href="?q=<%= encodeURIComponent(query) %>&type=commits<%= scope %>"> | |
| 36 | + Commits | |
| 37 | + <span><%= results.commits.length %></span> | |
| 38 | + </a> | |
| 39 | + <a class="<%= type === 'issues' ? 'active' : '' %>" href="?q=<%= encodeURIComponent(query) %>&type=issues<%= scope %>"> | |
| 40 | + Issues | |
| 41 | + <span><%= results.issues.length %></span> | |
| 42 | + </a> | |
| 43 | + <a class="<%= type === 'pulls' ? 'active' : '' %>" href="?q=<%= encodeURIComponent(query) %>&type=pulls<%= scope %>"> | |
| 44 | + Pull requests | |
| 45 | + <span><%= results.pulls.length %></span> | |
| 46 | + </a> | |
| 47 | + <a class="<%= type === 'releases' ? 'active' : '' %>" href="?q=<%= encodeURIComponent(query) %>&type=releases<%= scope %>"> | |
| 48 | + Releases | |
| 49 | + <span><%= results.releases.length %></span> | |
| 50 | + </a> | |
| 51 | + <a class="<%= type === 'repositories' ? 'active' : '' %>" href="?q=<%= encodeURIComponent(query) %>&type=repositories<%= scope %>"> | |
| 52 | + Repositories | |
| 53 | + <span><%= results.repositories.length %></span> | |
| 54 | + </a> | |
| 55 | + <% if (repository) { %> | |
| 56 | + <div class="search-scope"> | |
| 57 | + <strong>In this repository</strong> | |
| 58 | + <span><%= repository.name %></span> | |
| 59 | + <% if (selectedReference) { %><span><%= selectedReference.type %>: <%= selectedReference.name %></span><% } %> | |
| 60 | + <a href="?q=<%= encodeURIComponent(query) %>">Search all repositories</a> | |
| 61 | + </div> | |
| 62 | + <% } %> | |
| 63 | + </aside> | |
| 64 | + | |
| 65 | + <section class="search-results"> | |
| 66 | + <header class="search-heading"> | |
| 67 | + <% if (query) { %> | |
| 68 | + <p><%= repository ? repository.name + (selectedReference ? " · " + selectedReference.name : "") : "Snapshot" %></p> | |
| 69 | + <h1><%= visibleTotal %> <%= visibleTotal === 1 ? "result" : "results" %> for <strong><%= query %></strong></h1> | |
| 70 | + <% } else { %> | |
| 71 | + <p>Snapshot search</p> | |
| 72 | + <h1>Find code, commits, and repositories</h1> | |
| 73 | + <% } %> | |
| 74 | + </header> | |
| 75 | + | |
| 76 | + <% if (!query) { %> | |
| 77 | + <div class="search-empty"> | |
| 78 | + <strong>Search the frozen snapshot</strong> | |
| 79 | + <p>Enter code, a file name, commit, issue, pull request, release, author, or repository name.</p> | |
| 80 | + </div> | |
| 81 | + <% } else if (!visibleTotal) { %> | |
| 82 | + <div class="search-empty"> | |
| 83 | + <strong>No results matched <%= query %></strong> | |
| 84 | + <p>Try a shorter term or search all repositories in this snapshot.</p> | |
| 85 | + </div> | |
| 86 | + <% } %> | |
| 87 | + | |
| 88 | + <% if ((type === "all" || type === "code") && results.code.length) { %> | |
| 89 | + <section class="result-group"> | |
| 90 | + <header> | |
| 91 | + <h2>Code</h2> | |
| 92 | + <span><%= results.code.length %></span> | |
| 93 | + </header> | |
| 94 | + <% results.code.forEach((item) => { %> | |
| 95 | + <article class="code-result"> | |
| 96 | + <a class="result-path" href="<%= resultHref(item.repository.id, { file: item.file.path, view: item.view }) %>#L<%= item.lineNumber %>"> | |
| 97 | + <strong><%= item.repository.name %></strong> | |
| 98 | + <span>/</span> | |
| 99 | + <span><%= item.file.path %></span> | |
| 100 | + </a> | |
| 101 | + <% if (item.snippet) { %> | |
| 102 | + <a class="code-result-line" href="<%= resultHref(item.repository.id, { file: item.file.path, view: item.view }) %>#L<%= item.lineNumber %>"> | |
| 103 | + <span><%= item.lineNumber %></span> | |
| 104 | + <code><%= item.snippet %></code> | |
| 105 | + </a> | |
| 106 | + <% } else { %> | |
| 107 | + <p>File path matches this search.</p> | |
| 108 | + <% } %> | |
| 109 | + </article> | |
| 110 | + <% }) %> | |
| 111 | + </section> | |
| 112 | + <% } %> | |
| 113 | + | |
| 114 | + <% if ((type === "all" || type === "commits") && results.commits.length) { %> | |
| 115 | + <section class="result-group"> | |
| 116 | + <header> | |
| 117 | + <h2>Commits</h2> | |
| 118 | + <span><%= results.commits.length %></span> | |
| 119 | + </header> | |
| 120 | + <% results.commits.forEach(({ repository: resultRepository, commit }) => { %> | |
| 121 | + <article class="commit-result"> | |
| 122 | + <a href="<%= resultHref(resultRepository.id, { commit: commit.sha }) %>"> | |
| 123 | + <strong><%= commit.message.split("\n")[0] %></strong> | |
| 124 | + <span><%= resultRepository.name %> by <%= commit.author %> on <%= formatDate(commit.date) %></span> | |
| 125 | + </a> | |
| 126 | + <code><%= commit.sha.slice(0, 7) %></code> | |
| 127 | + </article> | |
| 128 | + <% }) %> | |
| 129 | + </section> | |
| 130 | + <% } %> | |
| 131 | + | |
| 132 | + <% if ((type === "all" || type === "issues") && results.issues.length) { %> | |
| 133 | + <section class="result-group"> | |
| 134 | + <header> | |
| 135 | + <h2>Issues</h2> | |
| 136 | + <span><%= results.issues.length %></span> | |
| 137 | + </header> | |
| 138 | + <% results.issues.forEach(({ repository: resultRepository, issue }) => { %> | |
| 139 | + <article class="work-search-result"> | |
| 140 | + <a href="<%= globalResultHref(resultRepository.id, { issue: issue.number }) %>"> | |
| 141 | + <strong><%= issue.title %></strong> | |
| 142 | + <span><%= resultRepository.name %> #<%= issue.number %></span> | |
| 143 | + </a> | |
| 144 | + <p><%= issue.body ? issue.body.slice(0, 220) : "No description was provided." %></p> | |
| 145 | + <span class="state-badge <%= issue.state %>"><%= issue.state %></span> | |
| 146 | + </article> | |
| 147 | + <% }) %> | |
| 148 | + </section> | |
| 149 | + <% } %> | |
| 150 | + | |
| 151 | + <% if ((type === "all" || type === "pulls") && results.pulls.length) { %> | |
| 152 | + <section class="result-group"> | |
| 153 | + <header> | |
| 154 | + <h2>Pull requests</h2> | |
| 155 | + <span><%= results.pulls.length %></span> | |
| 156 | + </header> | |
| 157 | + <% results.pulls.forEach(({ repository: resultRepository, pullRequest }) => { const pullState = pullRequest.draft ? "draft" : pullRequest.merged ? "merged" : pullRequest.state; %> | |
| 158 | + <article class="work-search-result"> | |
| 159 | + <a href="<%= globalResultHref(resultRepository.id, { pull: pullRequest.number }) %>"> | |
| 160 | + <strong><%= pullRequest.title %></strong> | |
| 161 | + <span><%= resultRepository.name %> #<%= pullRequest.number %></span> | |
| 162 | + </a> | |
| 163 | + <p><%= pullRequest.body ? pullRequest.body.slice(0, 220) : "No description was provided." %></p> | |
| 164 | + <span class="state-badge <%= pullState %>"><%= pullState %></span> | |
| 165 | + </article> | |
| 166 | + <% }) %> | |
| 167 | + </section> | |
| 168 | + <% } %> | |
| 169 | + | |
| 170 | + <% if ((type === "all" || type === "releases") && results.releases.length) { %> | |
| 171 | + <section class="result-group"> | |
| 172 | + <header> | |
| 173 | + <h2>Releases</h2> | |
| 174 | + <span><%= results.releases.length %></span> | |
| 175 | + </header> | |
| 176 | + <% results.releases.forEach(({ repository: resultRepository, release }) => { %> | |
| 177 | + <article class="work-search-result"> | |
| 178 | + <a href="<%= globalResultHref(resultRepository.id, { release: release.id }) %>"> | |
| 179 | + <strong><%= release.name %></strong> | |
| 180 | + <span><%= resultRepository.name %> · <%= release.tagName %></span> | |
| 181 | + </a> | |
| 182 | + <p><%= release.body ? release.body.slice(0, 220) : "No release notes were provided." %></p> | |
| 183 | + <span class="release-tag"><%= release.tagName %></span> | |
| 184 | + </article> | |
| 185 | + <% }) %> | |
| 186 | + </section> | |
| 187 | + <% } %> | |
| 188 | + | |
| 189 | + <% if ((type === "all" || type === "repositories") && results.repositories.length) { %> | |
| 190 | + <section class="result-group"> | |
| 191 | + <header> | |
| 192 | + <h2>Repositories</h2> | |
| 193 | + <span><%= results.repositories.length %></span> | |
| 194 | + </header> | |
| 195 | + <% results.repositories.forEach((resultRepository) => { %> | |
| 196 | + <article class="repository-result"> | |
| 197 | + <a href="<%= resultHref(resultRepository.id) %>"><%= resultRepository.name %></a> | |
| 198 | + <p><%= resultRepository.description || "No repository description." %></p> | |
| 199 | + <span><%= resultRepository.language || "Mixed" %> · <%= resultRepository.files.length %> files · <%= resultRepository.commits.length %> commits</span> | |
| 200 | + </article> | |
| 201 | + <% }) %> | |
| 202 | + </section> | |
| 203 | + <% } %> | |
| 204 | + </section> | |
| 205 | +</main> | |
| 206 | +<script src="/assets/app.js" defer></script> | |
| 207 | +<%- include("partials/foot") %> |
modified tests/auth.test.js +15 −0
| @@ -58,4 +58,19 @@describe("auth", () => { | ||
| 58 | 58 | expect(harness.calls.refreshes).toEqual(["refresh-me"]); |
| 59 | 59 | expect(harness.calls.repositoryTokens).toContain("refreshed-token"); |
| 60 | 60 | }); |
| 61 | + | |
| 62 | + it("signs the owner out and removes the session", async () => { | |
| 63 | + harness = makeHarness(); | |
| 64 | + const start = await harness.agent.get("/auth/github").expect(302); | |
| 65 | + const state = new URL(start.headers.location).searchParams.get("state"); | |
| 66 | + await harness.agent.get(`/auth/github/callback?code=ok&state=${state}`).expect(302); | |
| 67 | + | |
| 68 | + const response = await harness.agent.post("/auth/logout").expect(302); | |
| 69 | + expect(response.headers.location).toBe("/"); | |
| 70 | + expect(harness.store.sessionCount()).toBe(0); | |
| 71 | + | |
| 72 | + const page = await harness.agent.get("/").expect(200); | |
| 73 | + expect(page.text).toContain("Continue with GitHub"); | |
| 74 | + expect(page.text).not.toContain('action="/auth/logout"'); | |
| 75 | + }); | |
| 61 | 76 | }); |
modified tests/file-browser.test.js +46 −0
| @@ -12,5 +12,51 @@describe("file browser", () => { | ||
| 12 | 12 | expect(folder.text).toContain("index.js"); |
| 13 | 13 | const file = await harness.agent.get(`/s/${id}/repositories/101?path=src&file=src%2Findex.js`).expect(200); |
| 14 | 14 | expect(file.text).toContain("export const ready = true;"); |
| 15 | + expect(file.text).toContain('id="L1"'); | |
| 16 | + expect(file.text).toContain('href="#L1"'); | |
| 17 | + expect(file.text).toContain('data-copy-text="src/index.js"'); | |
| 18 | + expect(file.text).toContain("?history=src%2Findex.js"); | |
| 19 | + | |
| 20 | + const history = await harness.agent.get(`/s/${id}/repositories/101?history=src%2Findex.js`).expect(200); | |
| 21 | + expect(history.text).toContain("History for <code>src/index.js</code>"); | |
| 22 | + expect(history.text).toContain("Finish atlas viewer"); | |
| 23 | + expect(history.text).not.toContain("Start atlas"); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("previews Markdown and keeps source lines addressable", async () => { | |
| 27 | + harness = makeHarness(); | |
| 28 | + const { id } = await createShare(harness); | |
| 29 | + | |
| 30 | + const preview = await harness.agent | |
| 31 | + .get(`/s/${id}/repositories/101?file=docs%2Fguide.md`) | |
| 32 | + .expect(200); | |
| 33 | + expect(preview.text).toContain("<h1>Guide</h1>"); | |
| 34 | + expect(preview.text).toContain('class="active"'); | |
| 35 | + expect(preview.text).toContain("?file=docs%2Fguide.md&view=source"); | |
| 36 | + expect(preview.text).not.toContain('id="L1"'); | |
| 37 | + | |
| 38 | + const source = await harness.agent | |
| 39 | + .get(`/s/${id}/repositories/101?file=docs%2Fguide.md&view=source`) | |
| 40 | + .expect(200); | |
| 41 | + expect(source.text).toContain("# Guide"); | |
| 42 | + expect(source.text).toContain('id="L1"'); | |
| 43 | + expect(source.text).toContain('aria-current="page">Source</a>'); | |
| 44 | + | |
| 45 | + const large = await harness.agent | |
| 46 | + .get(`/s/${id}/repositories/101?file=docs%2Flarge.md`) | |
| 47 | + .expect(200); | |
| 48 | + expect(large.text).toContain("because it is larger than the snapshot preview limit."); | |
| 49 | + }); | |
| 50 | + | |
| 51 | + it("displays safe raster images stored in the snapshot", async () => { | |
| 52 | + harness = makeHarness(); | |
| 53 | + const { id } = await createShare(harness); | |
| 54 | + | |
| 55 | + const image = await harness.agent | |
| 56 | + .get(`/s/${id}/repositories/101?file=assets%2Flogo.png`) | |
| 57 | + .expect(200); | |
| 58 | + expect(image.text).toContain('src="data:image/png;base64,iVBORw0KGgo'); | |
| 59 | + expect(image.text).toContain('alt="logo.png"'); | |
| 60 | + expect(image.text).not.toContain("Download"); | |
| 15 | 61 | }); |
| 16 | 62 | }); |
modified tests/github-client.test.js +283 −0
| @@ -21,6 +21,16 @@function client() { | ||
| 21 | 21 | describe("production GitHub client", () => { |
| 22 | 22 | afterEach(() => vi.unstubAllGlobals()); |
| 23 | 23 | |
| 24 | + it("provides an actionable message when GitHub App permissions are missing", async () => { | |
| 25 | + vi.stubGlobal("fetch", vi.fn(async () => json( | |
| 26 | + { message: "Resource not accessible by integration" }, | |
| 27 | + { status: 403 }, | |
| 28 | + ))); | |
| 29 | + await expect(client().getViewer("token")).rejects.toMatchObject({ | |
| 30 | + publicMessage: expect.stringContaining("read-only Contents, Issues, Metadata, and Pull requests"), | |
| 31 | + }); | |
| 32 | + }); | |
| 33 | + | |
| 24 | 34 | it("snapshots an empty repository without requesting a branch, tree, or commits", async () => { |
| 25 | 35 | const calls = []; |
| 26 | 36 | vi.stubGlobal("fetch", vi.fn(async (url) => { |
| @@ -62,6 +72,14 @@describe("production GitHub client", () => { | ||
| 62 | 72 | if (target.pathname.endsWith("/commits/main")) { |
| 63 | 73 | return json({ sha: "fixed-head", commit: { tree: { sha: "fixed-tree" } } }); |
| 64 | 74 | } |
| 75 | + if (target.pathname.endsWith("/branches")) { | |
| 76 | + return json([{ name: "main", commit: { sha: "fixed-head" }, protected: true }]); | |
| 77 | + } | |
| 78 | + if (target.pathname.endsWith("/tags")) return json([]); | |
| 79 | + if (target.pathname.endsWith("/issues")) return json([]); | |
| 80 | + if (target.pathname.endsWith("/pulls")) return json([]); | |
| 81 | + if (target.pathname.endsWith("/releases")) return json([]); | |
| 82 | + if (target.pathname.endsWith("/languages")) return json({}); | |
| 65 | 83 | if (target.pathname.endsWith("/git/trees/fixed-tree")) { |
| 66 | 84 | return json({ truncated: false, tree: [] }); |
| 67 | 85 | } |
| @@ -78,6 +96,263 @@describe("production GitHub client", () => { | ||
| 78 | 96 | expect(calls.some((call) => call.includes("/commits?sha=fixed-head"))).toBe(true); |
| 79 | 97 | }); |
| 80 | 98 | |
| 99 | + it("freezes refs, releases, and repository metadata with deduplicated file content", async () => { | |
| 100 | + const calls = []; | |
| 101 | + vi.stubGlobal("fetch", vi.fn(async (url) => { | |
| 102 | + const target = new URL(url); | |
| 103 | + calls.push(`${target.pathname}${target.search}`); | |
| 104 | + if (target.pathname === "/repos/owner/project") { | |
| 105 | + return json({ | |
| 106 | + id: 2, | |
| 107 | + name: "project", | |
| 108 | + full_name: "owner/project", | |
| 109 | + description: null, | |
| 110 | + language: "JavaScript", | |
| 111 | + homepage: "https://project.test", | |
| 112 | + topics: ["tooling"], | |
| 113 | + license: { name: "MIT License", spdx_id: "MIT" }, | |
| 114 | + stargazers_count: 9, | |
| 115 | + forks_count: 2, | |
| 116 | + subscribers_count: 3, | |
| 117 | + archived: false, | |
| 118 | + default_branch: "main", | |
| 119 | + }); | |
| 120 | + } | |
| 121 | + if (target.pathname.endsWith("/commits/main")) { | |
| 122 | + return json({ sha: "main-head", commit: { tree: { sha: "main-tree" } } }); | |
| 123 | + } | |
| 124 | + if (target.pathname.endsWith("/branches")) { | |
| 125 | + return json([ | |
| 126 | + { name: "main", commit: { sha: "main-head" }, protected: true }, | |
| 127 | + { name: "feature", commit: { sha: "feature-head" }, protected: false }, | |
| 128 | + ]); | |
| 129 | + } | |
| 130 | + if (target.pathname.endsWith("/tags")) { | |
| 131 | + return json([{ name: "v1.0.0", commit: { sha: "tag-head" } }]); | |
| 132 | + } | |
| 133 | + if (target.pathname.endsWith("/issues")) return json([]); | |
| 134 | + if (target.pathname.endsWith("/pulls")) return json([]); | |
| 135 | + if (target.pathname.endsWith("/releases")) { | |
| 136 | + return json([{ | |
| 137 | + id: 10, | |
| 138 | + tag_name: "v1.0.0", | |
| 139 | + target_commitish: "main", | |
| 140 | + name: "First release", | |
| 141 | + body: "Release notes", | |
| 142 | + draft: false, | |
| 143 | + prerelease: false, | |
| 144 | + author: { login: "owner", avatar_url: "" }, | |
| 145 | + created_at: "2026-01-01T00:00:00Z", | |
| 146 | + published_at: "2026-01-02T00:00:00Z", | |
| 147 | + assets: [{ | |
| 148 | + id: 11, | |
| 149 | + name: "project.zip", | |
| 150 | + label: null, | |
| 151 | + size: 100, | |
| 152 | + download_count: 4, | |
| 153 | + content_type: "application/zip", | |
| 154 | + }], | |
| 155 | + }]); | |
| 156 | + } | |
| 157 | + if (target.pathname.endsWith("/languages")) return json({ JavaScript: 700, HTML: 300 }); | |
| 158 | + if (target.pathname.endsWith("/commits/feature-head")) { | |
| 159 | + return json({ sha: "feature-head", commit: { tree: { sha: "feature-tree" } } }); | |
| 160 | + } | |
| 161 | + if (target.pathname.endsWith("/commits/tag-head")) { | |
| 162 | + return json({ sha: "tag-head", commit: { tree: { sha: "tag-tree" } } }); | |
| 163 | + } | |
| 164 | + if (target.pathname.endsWith("/git/trees/main-tree")) { | |
| 165 | + return json({ | |
| 166 | + truncated: false, | |
| 167 | + tree: [ | |
| 168 | + { type: "blob", path: "README.md", sha: "shared-blob", size: 4 }, | |
| 169 | + { type: "blob", path: "logo.png", sha: "image-blob", size: 68 }, | |
| 170 | + ], | |
| 171 | + }); | |
| 172 | + } | |
| 173 | + if (target.pathname.endsWith("/git/trees/feature-tree")) { | |
| 174 | + return json({ truncated: false, tree: [{ type: "blob", path: "feature.js", sha: "feature-blob", size: 7 }] }); | |
| 175 | + } | |
| 176 | + if (target.pathname.endsWith("/git/trees/tag-tree")) { | |
| 177 | + return json({ truncated: false, tree: [{ type: "blob", path: "README.md", sha: "shared-blob", size: 4 }] }); | |
| 178 | + } | |
| 179 | + if (target.pathname.endsWith("/git/blobs/shared-blob")) { | |
| 180 | + return json({ encoding: "base64", content: Buffer.from("same").toString("base64") }); | |
| 181 | + } | |
| 182 | + if (target.pathname.endsWith("/git/blobs/feature-blob")) { | |
| 183 | + return json({ encoding: "base64", content: Buffer.from("feature").toString("base64") }); | |
| 184 | + } | |
| 185 | + if (target.pathname.endsWith("/git/blobs/image-blob")) { | |
| 186 | + return json({ | |
| 187 | + encoding: "base64", | |
| 188 | + content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2V6sAAAAASUVORK5CYII=", | |
| 189 | + }); | |
| 190 | + } | |
| 191 | + if (target.pathname.endsWith("/commits") && target.searchParams.has("sha")) { | |
| 192 | + const sha = target.searchParams.get("sha"); | |
| 193 | + return json([{ | |
| 194 | + sha: `${sha}-commit`, | |
| 195 | + commit: { | |
| 196 | + message: `Commit ${sha}`, | |
| 197 | + author: { name: "Owner", date: "2026-01-01T00:00:00Z" }, | |
| 198 | + }, | |
| 199 | + }]); | |
| 200 | + } | |
| 201 | + if (/\/commits\/[^/]+-commit$/.test(target.pathname)) { | |
| 202 | + return json({ stats: { additions: 1, deletions: 0 }, files: [] }); | |
| 203 | + } | |
| 204 | + throw new Error(`Unexpected request: ${target}`); | |
| 205 | + })); | |
| 206 | + | |
| 207 | + const [snapshot] = await client().snapshotRepositories("token", [{ | |
| 208 | + id: 2, | |
| 209 | + fullName: "owner/project", | |
| 210 | + defaultBranch: "main", | |
| 211 | + }]); | |
| 212 | + | |
| 213 | + expect(snapshot.branches.map((branch) => branch.name)).toEqual(["main", "feature"]); | |
| 214 | + expect(snapshot.tags).toEqual([{ name: "v1.0.0", sha: "tag-head" }]); | |
| 215 | + expect(snapshot.files[0].content).toBe("same"); | |
| 216 | + expect(snapshot.files[1]).toMatchObject({ | |
| 217 | + path: "logo.png", | |
| 218 | + content: null, | |
| 219 | + mediaType: "image/png", | |
| 220 | + binaryContent: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2V6sAAAAASUVORK5CYII=", | |
| 221 | + }); | |
| 222 | + expect(snapshot.refSnapshots.find((item) => item.sha === "feature-head").files[0].content).toBe("feature"); | |
| 223 | + expect(snapshot.refSnapshots.find((item) => item.sha === "tag-head").files[0].content).toBe("same"); | |
| 224 | + expect(calls.filter((call) => call.includes("/git/blobs/"))).toHaveLength(3); | |
| 225 | + expect(snapshot.releases[0].assets[0].name).toBe("project.zip"); | |
| 226 | + expect(snapshot.languages).toEqual([ | |
| 227 | + { name: "JavaScript", bytes: 700, percent: 70 }, | |
| 228 | + { name: "HTML", bytes: 300, percent: 30 }, | |
| 229 | + ]); | |
| 230 | + expect(snapshot.license.spdxId).toBe("MIT"); | |
| 231 | + expect(snapshot.stargazersCount).toBe(9); | |
| 232 | + }); | |
| 233 | + | |
| 234 | + it("captures issue conversations and pull request reviews", async () => { | |
| 235 | + vi.stubGlobal("fetch", vi.fn(async (url) => { | |
| 236 | + const target = new URL(url); | |
| 237 | + if (target.pathname === "/repos/owner/project") { | |
| 238 | + return json({ | |
| 239 | + id: 2, | |
| 240 | + name: "project", | |
| 241 | + full_name: "owner/project", | |
| 242 | + description: null, | |
| 243 | + language: "JavaScript", | |
| 244 | + default_branch: "main", | |
| 245 | + }); | |
| 246 | + } | |
| 247 | + if (target.pathname.endsWith("/commits/main")) { | |
| 248 | + return json({ sha: "head", commit: { tree: { sha: "tree" } } }); | |
| 249 | + } | |
| 250 | + if (target.pathname.endsWith("/branches")) { | |
| 251 | + return json([{ name: "main", commit: { sha: "head" }, protected: false }]); | |
| 252 | + } | |
| 253 | + if (target.pathname.endsWith("/tags")) return json([]); | |
| 254 | + if (target.pathname.endsWith("/issues")) { | |
| 255 | + return json([{ | |
| 256 | + number: 7, | |
| 257 | + title: "Keyboard navigation", | |
| 258 | + body: "Use the repository without a mouse.", | |
| 259 | + state: "open", | |
| 260 | + state_reason: null, | |
| 261 | + locked: false, | |
| 262 | + user: { login: "owner", avatar_url: "https://avatars.test/owner" }, | |
| 263 | + labels: [{ name: "accessibility", color: "0969da" }], | |
| 264 | + created_at: "2026-01-01T00:00:00Z", | |
| 265 | + updated_at: "2026-01-02T00:00:00Z", | |
| 266 | + closed_at: null, | |
| 267 | + }]); | |
| 268 | + } | |
| 269 | + if (target.pathname.endsWith("/pulls")) { | |
| 270 | + return json([{ number: 8 }]); | |
| 271 | + } | |
| 272 | + if (target.pathname.endsWith("/releases")) return json([]); | |
| 273 | + if (target.pathname.endsWith("/languages")) return json({}); | |
| 274 | + if (target.pathname.endsWith("/git/trees/tree")) { | |
| 275 | + return json({ truncated: false, tree: [] }); | |
| 276 | + } | |
| 277 | + if (target.pathname.endsWith("/commits") && target.searchParams.has("sha")) return json([]); | |
| 278 | + if (target.pathname.endsWith("/issues/7/comments")) { | |
| 279 | + return json([{ | |
| 280 | + id: 71, | |
| 281 | + body: "Tab order is fixed.", | |
| 282 | + user: { login: "reviewer", avatar_url: "" }, | |
| 283 | + created_at: "2026-01-02T00:00:00Z", | |
| 284 | + updated_at: "2026-01-02T00:00:00Z", | |
| 285 | + }]); | |
| 286 | + } | |
| 287 | + if (target.pathname.endsWith("/pulls/8")) { | |
| 288 | + return json({ | |
| 289 | + number: 8, | |
| 290 | + title: "Add keyboard shortcuts", | |
| 291 | + body: "Adds focused navigation.", | |
| 292 | + state: "open", | |
| 293 | + draft: false, | |
| 294 | + merged_at: null, | |
| 295 | + mergeable_state: "clean", | |
| 296 | + user: { login: "owner", avatar_url: "" }, | |
| 297 | + labels: [], | |
| 298 | + base: { ref: "main" }, | |
| 299 | + head: { ref: "keyboard" }, | |
| 300 | + created_at: "2026-01-01T00:00:00Z", | |
| 301 | + updated_at: "2026-01-03T00:00:00Z", | |
| 302 | + closed_at: null, | |
| 303 | + additions: 4, | |
| 304 | + deletions: 1, | |
| 305 | + changed_files: 1, | |
| 306 | + commits: 2, | |
| 307 | + }); | |
| 308 | + } | |
| 309 | + if (target.pathname.endsWith("/issues/8/comments")) { | |
| 310 | + return json([{ | |
| 311 | + id: 81, | |
| 312 | + body: "Ready for review.", | |
| 313 | + user: { login: "owner", avatar_url: "" }, | |
| 314 | + created_at: "2026-01-02T00:00:00Z", | |
| 315 | + updated_at: "2026-01-02T00:00:00Z", | |
| 316 | + }]); | |
| 317 | + } | |
| 318 | + if (target.pathname.endsWith("/pulls/8/reviews")) { | |
| 319 | + return json([{ | |
| 320 | + id: 82, | |
| 321 | + body: "Approved.", | |
| 322 | + state: "APPROVED", | |
| 323 | + user: { login: "reviewer", avatar_url: "" }, | |
| 324 | + submitted_at: "2026-01-03T00:00:00Z", | |
| 325 | + }]); | |
| 326 | + } | |
| 327 | + if (target.pathname.endsWith("/pulls/8/comments")) return json([]); | |
| 328 | + if (target.pathname.endsWith("/pulls/8/files")) { | |
| 329 | + return json([{ | |
| 330 | + filename: "src/index.js", | |
| 331 | + status: "modified", | |
| 332 | + additions: 4, | |
| 333 | + deletions: 1, | |
| 334 | + changes: 5, | |
| 335 | + patch: "+shortcut", | |
| 336 | + }]); | |
| 337 | + } | |
| 338 | + throw new Error(`Unexpected request: ${target}`); | |
| 339 | + })); | |
| 340 | + | |
| 341 | + const [snapshot] = await client().snapshotRepositories("token", [{ | |
| 342 | + id: 2, | |
| 343 | + fullName: "owner/project", | |
| 344 | + defaultBranch: "main", | |
| 345 | + }]); | |
| 346 | + | |
| 347 | + expect(snapshot.issues[0].comments[0].body).toBe("Tab order is fixed."); | |
| 348 | + expect(snapshot.issues[0].labels[0].name).toBe("accessibility"); | |
| 349 | + expect(snapshot.pullRequests[0].conversation.map((item) => item.body)).toEqual([ | |
| 350 | + "Ready for review.", | |
| 351 | + "Approved.", | |
| 352 | + ]); | |
| 353 | + expect(snapshot.pullRequests[0].files[0].patch).toBe("+shortcut"); | |
| 354 | + }); | |
| 355 | + | |
| 81 | 356 | it("collects every changed-file page for a large commit", async () => { |
| 82 | 357 | vi.stubGlobal("fetch", vi.fn(async (url) => { |
| 83 | 358 | const target = new URL(url); |
| @@ -94,6 +369,14 @@describe("production GitHub client", () => { | ||
| 94 | 369 | if (target.pathname.endsWith("/commits/main")) { |
| 95 | 370 | return json({ sha: "head", commit: { tree: { sha: "tree" } } }); |
| 96 | 371 | } |
| 372 | + if (target.pathname.endsWith("/branches")) { | |
| 373 | + return json([{ name: "main", commit: { sha: "head" }, protected: false }]); | |
| 374 | + } | |
| 375 | + if (target.pathname.endsWith("/tags")) return json([]); | |
| 376 | + if (target.pathname.endsWith("/issues")) return json([]); | |
| 377 | + if (target.pathname.endsWith("/pulls")) return json([]); | |
| 378 | + if (target.pathname.endsWith("/releases")) return json([]); | |
| 379 | + if (target.pathname.endsWith("/languages")) return json({}); | |
| 97 | 380 | if (target.pathname.endsWith("/git/trees/tree")) return json({ truncated: false, tree: [] }); |
| 98 | 381 | if (target.pathname.endsWith("/commits") && !target.searchParams.has("page")) { |
| 99 | 382 | throw new Error("Pagination parameters expected"); |
modified tests/helpers.js +190 −36
| @@ -28,50 +28,204 @@export const repositories = [ | ||
| 28 | 28 | ]; |
| 29 | 29 | |
| 30 | 30 | function snapshot(repo) { |
| 31 | + const headSha = `${repo.id}`.padEnd(40, "a"); | |
| 32 | + const branchSha = `${repo.id + 1}`.padEnd(40, "c"); | |
| 33 | + const tagSha = `${repo.id + 2}`.padEnd(40, "d"); | |
| 34 | + const files = [ | |
| 35 | + { | |
| 36 | + path: "README.md", | |
| 37 | + size: 114, | |
| 38 | + content: `# ${repo.name}\n\nSnapshot\n\n## Highlights\n\n- Read-only review\n- [Read the guide](docs/guide.md)\n\n<script>alert("x")</script>`, | |
| 39 | + truncated: false, | |
| 40 | + }, | |
| 41 | + { path: "docs/guide.md", size: 26, content: "# Guide\n\nProject details.", truncated: false }, | |
| 42 | + { path: "docs/large.md", size: 600_000, content: null, truncated: true }, | |
| 43 | + { | |
| 44 | + path: "assets/logo.png", | |
| 45 | + size: 68, | |
| 46 | + content: null, | |
| 47 | + binaryContent: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2V6sAAAAASUVORK5CYII=", | |
| 48 | + mediaType: "image/png", | |
| 49 | + truncated: false, | |
| 50 | + }, | |
| 51 | + { path: "src/index.js", size: 22, content: "export const ready = true;", truncated: false }, | |
| 52 | + ]; | |
| 53 | + const commits = [ | |
| 54 | + { | |
| 55 | + sha: headSha, | |
| 56 | + message: `Finish ${repo.name} viewer`, | |
| 57 | + author: "Rasmus", | |
| 58 | + date: "2026-07-22T09:00:00.000Z", | |
| 59 | + additions: 4, | |
| 60 | + deletions: 1, | |
| 61 | + files: [{ | |
| 62 | + filename: "src/index.js", | |
| 63 | + status: "modified", | |
| 64 | + additions: 4, | |
| 65 | + deletions: 1, | |
| 66 | + patch: "@@ -1 +1 @@\n-false\n+true", | |
| 67 | + }, { | |
| 68 | + filename: "package-lock.json", | |
| 69 | + status: "modified", | |
| 70 | + additions: 1, | |
| 71 | + deletions: 1, | |
| 72 | + patch: "", | |
| 73 | + }], | |
| 74 | + }, | |
| 75 | + { | |
| 76 | + sha: `${repo.id}`.padEnd(40, "b"), | |
| 77 | + message: `Start ${repo.name}`, | |
| 78 | + author: "Rasmus", | |
| 79 | + date: "2026-07-19T09:00:00.000Z", | |
| 80 | + additions: 10, | |
| 81 | + deletions: 0, | |
| 82 | + files: [], | |
| 83 | + }, | |
| 84 | + ]; | |
| 31 | 85 | return { |
| 32 | 86 | ...repo, |
| 33 | - files: [ | |
| 34 | - { | |
| 35 | - path: "README.md", | |
| 36 | - size: 114, | |
| 37 | - content: `# ${repo.name}\n\nSnapshot\n\n## Highlights\n\n- Read-only review\n- [Read the guide](docs/guide.md)\n\n<script>alert("x")</script>`, | |
| 38 | - truncated: false, | |
| 39 | - }, | |
| 40 | - { path: "docs/guide.md", size: 26, content: "# Guide\n\nProject details.", truncated: false }, | |
| 41 | - { path: "src/index.js", size: 22, content: "export const ready = true;", truncated: false }, | |
| 87 | + headSha, | |
| 88 | + createdAt: "2026-06-01T10:00:00.000Z", | |
| 89 | + homepage: `https://${repo.name}.test`, | |
| 90 | + topics: ["developer-tools", "snapshot"], | |
| 91 | + license: { name: "MIT License", spdxId: "MIT" }, | |
| 92 | + stargazersCount: 12, | |
| 93 | + forksCount: 3, | |
| 94 | + watchersCount: 4, | |
| 95 | + archived: false, | |
| 96 | + languages: [ | |
| 97 | + { name: repo.language, bytes: 700, percent: 70 }, | |
| 98 | + { name: "HTML", bytes: 300, percent: 30 }, | |
| 42 | 99 | ], |
| 43 | - commits: [ | |
| 44 | - { | |
| 45 | - sha: `${repo.id}`.padEnd(40, "a"), | |
| 46 | - message: `Finish ${repo.name} viewer`, | |
| 100 | + branches: [ | |
| 101 | + { name: repo.defaultBranch, sha: headSha, protected: true }, | |
| 102 | + { name: "review-notes", sha: branchSha, protected: false }, | |
| 103 | + ], | |
| 104 | + tags: [{ name: "v1.0.0", sha: tagSha }], | |
| 105 | + refSnapshots: [{ | |
| 106 | + sha: branchSha, | |
| 107 | + files: [ | |
| 108 | + { path: "README.md", size: 33, content: `# ${repo.name}\n\nReview notes branch.`, truncated: false }, | |
| 109 | + { path: "src/preview.js", size: 43, content: 'export const branch = "review-notes";', truncated: false }, | |
| 110 | + ], | |
| 111 | + commits: [{ | |
| 112 | + sha: branchSha, | |
| 113 | + message: `Add ${repo.name} review notes`, | |
| 47 | 114 | author: "Rasmus", |
| 48 | - date: "2026-07-22T09:00:00.000Z", | |
| 49 | - additions: 4, | |
| 50 | - deletions: 1, | |
| 115 | + date: "2026-07-23T08:00:00.000Z", | |
| 116 | + additions: 7, | |
| 117 | + deletions: 0, | |
| 51 | 118 | files: [{ |
| 52 | - filename: "src/index.js", | |
| 53 | - status: "modified", | |
| 54 | - additions: 4, | |
| 55 | - deletions: 1, | |
| 56 | - patch: "@@ -1 +1 @@\n-false\n+true", | |
| 57 | - }, { | |
| 58 | - filename: "package-lock.json", | |
| 59 | - status: "modified", | |
| 60 | - additions: 1, | |
| 61 | - deletions: 1, | |
| 62 | - patch: "", | |
| 119 | + filename: "src/preview.js", | |
| 120 | + status: "added", | |
| 121 | + additions: 7, | |
| 122 | + deletions: 0, | |
| 123 | + patch: '+export const branch = "review-notes";', | |
| 63 | 124 | }], |
| 64 | - }, | |
| 65 | - { | |
| 66 | - sha: `${repo.id}`.padEnd(40, "b"), | |
| 67 | - message: `Start ${repo.name}`, | |
| 125 | + }], | |
| 126 | + }, { | |
| 127 | + sha: tagSha, | |
| 128 | + files: [ | |
| 129 | + { path: "README.md", size: 26, content: `# ${repo.name} v1.0.0`, truncated: false }, | |
| 130 | + { path: "CHANGELOG.md", size: 24, content: "# Changes\n\nFirst release.", truncated: false }, | |
| 131 | + ], | |
| 132 | + commits: [{ | |
| 133 | + sha: tagSha, | |
| 134 | + message: `Release ${repo.name} v1.0.0`, | |
| 68 | 135 | author: "Rasmus", |
| 69 | - date: "2026-07-19T09:00:00.000Z", | |
| 70 | - additions: 10, | |
| 71 | - deletions: 0, | |
| 136 | + date: "2026-07-21T08:00:00.000Z", | |
| 137 | + additions: 12, | |
| 138 | + deletions: 1, | |
| 72 | 139 | files: [], |
| 73 | - }, | |
| 74 | - ], | |
| 140 | + }], | |
| 141 | + }], | |
| 142 | + issues: [{ | |
| 143 | + number: 12, | |
| 144 | + title: `Improve ${repo.name} keyboard navigation`, | |
| 145 | + body: "The file browser should be usable without a mouse.", | |
| 146 | + state: "open", | |
| 147 | + stateReason: null, | |
| 148 | + locked: false, | |
| 149 | + author: { login: "reviewer", avatarUrl: "https://avatars.test/reviewer" }, | |
| 150 | + labels: [{ name: "accessibility", color: "0969da" }], | |
| 151 | + createdAt: "2026-07-20T08:00:00.000Z", | |
| 152 | + updatedAt: "2026-07-23T07:00:00.000Z", | |
| 153 | + closedAt: null, | |
| 154 | + comments: [{ | |
| 155 | + id: 1201, | |
| 156 | + type: "comment", | |
| 157 | + body: "Focus should return to the branch button after closing the menu.", | |
| 158 | + author: { login: "rasmus", avatarUrl: "https://avatars.test/rasmus" }, | |
| 159 | + createdAt: "2026-07-21T08:00:00.000Z", | |
| 160 | + updatedAt: "2026-07-21T08:00:00.000Z", | |
| 161 | + state: null, | |
| 162 | + path: null, | |
| 163 | + line: null, | |
| 164 | + }], | |
| 165 | + }], | |
| 166 | + pullRequests: [{ | |
| 167 | + number: 14, | |
| 168 | + title: `Add ${repo.name} keyboard shortcuts`, | |
| 169 | + body: "Adds focused navigation for repository reviewers.", | |
| 170 | + state: "open", | |
| 171 | + draft: false, | |
| 172 | + merged: false, | |
| 173 | + mergeableState: "clean", | |
| 174 | + author: { login: "rasmus", avatarUrl: "https://avatars.test/rasmus" }, | |
| 175 | + labels: [{ name: "enhancement", color: "1f883d" }], | |
| 176 | + base: "main", | |
| 177 | + head: "keyboard-navigation", | |
| 178 | + createdAt: "2026-07-21T10:00:00.000Z", | |
| 179 | + updatedAt: "2026-07-23T10:00:00.000Z", | |
| 180 | + closedAt: null, | |
| 181 | + mergedAt: null, | |
| 182 | + additions: 8, | |
| 183 | + deletions: 2, | |
| 184 | + changedFiles: 1, | |
| 185 | + commitCount: 2, | |
| 186 | + conversation: [{ | |
| 187 | + id: 1401, | |
| 188 | + type: "review", | |
| 189 | + body: "The keyboard flow works as expected.", | |
| 190 | + author: { login: "reviewer", avatarUrl: "https://avatars.test/reviewer" }, | |
| 191 | + createdAt: "2026-07-22T10:00:00.000Z", | |
| 192 | + updatedAt: "2026-07-22T10:00:00.000Z", | |
| 193 | + state: "APPROVED", | |
| 194 | + path: null, | |
| 195 | + line: null, | |
| 196 | + }], | |
| 197 | + files: [{ | |
| 198 | + filename: "src/index.js", | |
| 199 | + previousFilename: null, | |
| 200 | + status: "modified", | |
| 201 | + additions: 8, | |
| 202 | + deletions: 2, | |
| 203 | + changes: 10, | |
| 204 | + patch: "@@ -1 +1,2 @@\n export const ready = true;\n+export const keyboard = true;", | |
| 205 | + }], | |
| 206 | + }], | |
| 207 | + releases: [{ | |
| 208 | + id: repo.id * 10 + 1, | |
| 209 | + tagName: "v1.0.0", | |
| 210 | + targetCommitish: "main", | |
| 211 | + name: `${repo.name} v1.0.0`, | |
| 212 | + body: "## Highlights\n\n- Faster repository reviews\n- Frozen release notes", | |
| 213 | + draft: false, | |
| 214 | + prerelease: false, | |
| 215 | + author: { login: "rasmus", avatarUrl: "https://avatars.test/rasmus" }, | |
| 216 | + createdAt: "2026-07-20T10:00:00.000Z", | |
| 217 | + publishedAt: "2026-07-21T10:00:00.000Z", | |
| 218 | + assets: [{ | |
| 219 | + id: repo.id * 100 + 1, | |
| 220 | + name: `${repo.name}-v1.0.0.zip`, | |
| 221 | + label: "Source archive", | |
| 222 | + size: 2048, | |
| 223 | + downloadCount: 8, | |
| 224 | + contentType: "application/zip", | |
| 225 | + }], | |
| 226 | + }], | |
| 227 | + files, | |
| 228 | + commits, | |
| 75 | 229 | }; |
| 76 | 230 | } |
| 77 | 231 |
added tests/issues-pulls.test.js +65 −0
| @@ -0,0 +1,65 @@ | ||
| 1 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 2 | +import { createShare, makeHarness } from "./helpers.js"; | |
| 3 | + | |
| 4 | +describe("issues and pull requests", () => { | |
| 5 | + let harness; | |
| 6 | + afterEach(() => harness.close()); | |
| 7 | + | |
| 8 | + it("shows frozen issue lists, filters, conversations, and labels", async () => { | |
| 9 | + harness = makeHarness(); | |
| 10 | + const { id } = await createShare(harness); | |
| 11 | + | |
| 12 | + const list = await harness.agent.get(`/s/${id}/repositories/101?tab=issues`).expect(200); | |
| 13 | + expect(list.text).toContain("Improve atlas keyboard navigation"); | |
| 14 | + expect(list.text).toContain("accessibility"); | |
| 15 | + expect(list.text).toContain("?issue=12"); | |
| 16 | + | |
| 17 | + const closed = await harness.agent | |
| 18 | + .get(`/s/${id}/repositories/101?tab=issues&state=closed`) | |
| 19 | + .expect(200); | |
| 20 | + expect(closed.text).toContain("No closed issues are included in this snapshot."); | |
| 21 | + | |
| 22 | + const detail = await harness.agent.get(`/s/${id}/repositories/101?issue=12`).expect(200); | |
| 23 | + expect(detail.text).toContain("The file browser should be usable without a mouse."); | |
| 24 | + expect(detail.text).toContain("Focus should return to the branch button"); | |
| 25 | + expect(detail.text).toContain("Read only"); | |
| 26 | + expect(detail.text).not.toMatch(/<form[^>]+method=["']?post/i); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it("shows frozen pull request reviews and changed-file diffs", async () => { | |
| 30 | + harness = makeHarness(); | |
| 31 | + const { id } = await createShare(harness); | |
| 32 | + | |
| 33 | + const list = await harness.agent.get(`/s/${id}/repositories/101?tab=pulls`).expect(200); | |
| 34 | + expect(list.text).toContain("Add atlas keyboard shortcuts"); | |
| 35 | + expect(list.text).toContain("enhancement"); | |
| 36 | + expect(list.text).toContain("?pull=14"); | |
| 37 | + | |
| 38 | + const detail = await harness.agent.get(`/s/${id}/repositories/101?pull=14`).expect(200); | |
| 39 | + expect(detail.text).toContain("keyboard-navigation"); | |
| 40 | + expect(detail.text).toContain("The keyboard flow works as expected."); | |
| 41 | + expect(detail.text).toContain("Files changed"); | |
| 42 | + expect(detail.text).toContain("src/index.js"); | |
| 43 | + expect(detail.text).toContain("export const keyboard = true;"); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("finds issues and pull requests through snapshot search", async () => { | |
| 47 | + harness = makeHarness(); | |
| 48 | + const { id } = await createShare(harness); | |
| 49 | + | |
| 50 | + const issues = await harness.agent.get(`/s/${id}/search?q=without+a+mouse&type=issues`).expect(200); | |
| 51 | + expect(issues.text).toContain("Improve atlas keyboard navigation"); | |
| 52 | + expect(issues.text).toContain(`repositories/101?issue=12`); | |
| 53 | + | |
| 54 | + const pulls = await harness.agent.get(`/s/${id}/search?q=keyboard+flow&type=pulls`).expect(200); | |
| 55 | + expect(pulls.text).toContain("Add atlas keyboard shortcuts"); | |
| 56 | + expect(pulls.text).toContain(`repositories/101?pull=14`); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("rejects issue and pull request numbers outside the snapshot", async () => { | |
| 60 | + harness = makeHarness(); | |
| 61 | + const { id } = await createShare(harness); | |
| 62 | + await harness.agent.get(`/s/${id}/repositories/101?issue=999`).expect(404); | |
| 63 | + await harness.agent.get(`/s/${id}/repositories/101?pull=999`).expect(404); | |
| 64 | + }); | |
| 65 | +}); |
modified tests/layout.test.js +5 −3
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | import { readFileSync } from "node:fs"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | -describe("desktop layout", () => { | |
| 5 | - it("uses a desktop canvas and structured repository navigation", () => { | |
| 4 | +describe("responsive layout", () => { | |
| 5 | + it("keeps structured repository navigation without forcing a desktop canvas", () => { | |
| 6 | 6 | const css = readFileSync(new URL("../src/public/styles.css", import.meta.url), "utf8"); |
| 7 | - expect(css).toContain("min-width: 1024px"); | |
| 7 | + expect(css).not.toContain("min-width: 1024px"); | |
| 8 | + expect(css).toContain("@media (max-width: 900px)"); | |
| 9 | + expect(css).toContain("@media (max-width: 640px)"); | |
| 8 | 10 | expect(css).toMatch(/repo-tabs-inner\s*\{[^}]*display: flex/); |
| 9 | 11 | expect(css).toMatch(/latest-commit\s*\{[^}]*grid-template-columns:/); |
| 10 | 12 | expect(css).toMatch(/markdown-body\s*\{[^}]*padding:/); |
modified tests/profile-page.test.js +13 −0
| @@ -20,4 +20,17 @@describe("profile page", () => { | ||
| 20 | 20 | expect(page.headers["set-cookie"]).toBeUndefined(); |
| 21 | 21 | expect(harness.store.sessionCount()).toBe(sessionsBefore); |
| 22 | 22 | }); |
| 23 | + | |
| 24 | + it("provides a GitHub-style repository tab with search and language filters", async () => { | |
| 25 | + harness = makeHarness(); | |
| 26 | + const { id } = await createShare(harness, [101, 202]); | |
| 27 | + const page = await harness.agent | |
| 28 | + .get(`/s/${id}?tab=repositories&q=expense&language=TypeScript`) | |
| 29 | + .expect(200); | |
| 30 | + expect(page.text).toContain('aria-current="page"'); | |
| 31 | + expect(page.text).toContain("Find a repository"); | |
| 32 | + expect(page.text).toContain(">ledger</a>"); | |
| 33 | + expect(page.text).not.toContain(">atlas</a>"); | |
| 34 | + expect(page.text).toContain("Open repository"); | |
| 35 | + }); | |
| 23 | 36 | }); |
modified tests/read-only.test.js +2 −1
| @@ -9,7 +9,8 @@describe("read only", () => { | ||
| 9 | 9 | harness = makeHarness(); |
| 10 | 10 | const { id } = await createShare(harness); |
| 11 | 11 | const page = await harness.agent.get(`/s/${id}/repositories/101`).expect(200); |
| 12 | - expect(page.text).not.toMatch(/<form|download=|add comment|edit file/i); | |
| 12 | + expect(page.text).not.toMatch(/<form[^>]+method=["']?post|download=|add comment|edit file/i); | |
| 13 | + expect(page.text).toContain('method="get" role="search"'); | |
| 13 | 14 | expect(page.text).toContain("Read-only snapshot"); |
| 14 | 15 | }); |
| 15 | 16 | }); |
added tests/refs.test.js +99 −0
| @@ -0,0 +1,99 @@ | ||
| 1 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 2 | +import { authenticate, createShare, makeHarness } from "./helpers.js"; | |
| 3 | + | |
| 4 | +describe("branches and tags", () => { | |
| 5 | + let harness; | |
| 6 | + afterEach(() => harness.close()); | |
| 7 | + | |
| 8 | + it("switches between frozen branch and tag contents", async () => { | |
| 9 | + harness = makeHarness(); | |
| 10 | + const { id } = await createShare(harness); | |
| 11 | + | |
| 12 | + const defaultView = await harness.agent.get(`/s/${id}/repositories/101`).expect(200); | |
| 13 | + expect(defaultView.text).toContain("Switch branches or tags"); | |
| 14 | + expect(defaultView.text).toContain("review-notes"); | |
| 15 | + expect(defaultView.text).toContain("v1.0.0"); | |
| 16 | + | |
| 17 | + const branchView = await harness.agent | |
| 18 | + .get(`/s/${id}/repositories/101?ref=review-notes&refType=branch`) | |
| 19 | + .expect(200); | |
| 20 | + expect(branchView.text).toContain("path=src"); | |
| 21 | + expect(branchView.text).toContain("Add atlas review notes"); | |
| 22 | + expect(branchView.text).toContain('name="ref" value="review-notes"'); | |
| 23 | + | |
| 24 | + const branchFolder = await harness.agent | |
| 25 | + .get(`/s/${id}/repositories/101?ref=review-notes&refType=branch&path=src`) | |
| 26 | + .expect(200); | |
| 27 | + expect(branchFolder.text).toContain("preview.js"); | |
| 28 | + expect(branchFolder.text).not.toContain(">index.js</strong>"); | |
| 29 | + expect(branchFolder.text).toContain("?ref=review-notes&refType=branch&path=src&file=src%2Fpreview.js"); | |
| 30 | + | |
| 31 | + const tagView = await harness.agent | |
| 32 | + .get(`/s/${id}/repositories/101?ref=v1.0.0&refType=tag`) | |
| 33 | + .expect(200); | |
| 34 | + expect(tagView.text).toContain("CHANGELOG.md"); | |
| 35 | + expect(tagView.text).toContain("Release atlas v1.0.0"); | |
| 36 | + expect(tagView.text).not.toContain(">preview.js</strong>"); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it("keeps file, commit, and search navigation on the selected branch", async () => { | |
| 40 | + harness = makeHarness(); | |
| 41 | + const { id } = await createShare(harness); | |
| 42 | + | |
| 43 | + const file = await harness.agent | |
| 44 | + .get(`/s/${id}/repositories/101?ref=review-notes&refType=branch&file=src%2Fpreview.js`) | |
| 45 | + .expect(200); | |
| 46 | + expect(file.text).toContain('export const branch = "review-notes";'); | |
| 47 | + expect(file.text).toContain("?ref=review-notes&refType=branch&history=src%2Fpreview.js"); | |
| 48 | + | |
| 49 | + const commits = await harness.agent | |
| 50 | + .get(`/s/${id}/repositories/101?ref=review-notes&refType=branch&tab=commits`) | |
| 51 | + .expect(200); | |
| 52 | + expect(commits.text).toContain("Add atlas review notes"); | |
| 53 | + expect(commits.text).not.toContain("Finish atlas viewer"); | |
| 54 | + | |
| 55 | + const search = await harness.agent | |
| 56 | + .get(`/s/${id}/search?q=Review+notes&repository=101&ref=review-notes&refType=branch`) | |
| 57 | + .expect(200); | |
| 58 | + expect(search.text).toContain("README.md"); | |
| 59 | + expect(search.text).toContain("?ref=review-notes&refType=branch&file=README.md&view=source#L3"); | |
| 60 | + }); | |
| 61 | + | |
| 62 | + it("rejects references that were not included in the snapshot", async () => { | |
| 63 | + harness = makeHarness(); | |
| 64 | + const { id } = await createShare(harness); | |
| 65 | + const page = await harness.agent | |
| 66 | + .get(`/s/${id}/repositories/101?ref=missing&refType=branch`) | |
| 67 | + .expect(404); | |
| 68 | + expect(page.text).toContain("This branch or tag is not part of the snapshot."); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("keeps snapshots created before ref support browsable", async () => { | |
| 72 | + harness = makeHarness(); | |
| 73 | + await authenticate(harness); | |
| 74 | + harness.store.createShare({ | |
| 75 | + id: "legacy-share", | |
| 76 | + ownerId: 77, | |
| 77 | + createdAt: "2026-07-23T12:00:00.000Z", | |
| 78 | + expiresAt: "2026-07-30T12:00:00.000Z", | |
| 79 | + snapshot: { | |
| 80 | + profile: { login: "rasmus", name: "Rasmus", avatarUrl: "", bio: "" }, | |
| 81 | + repositories: [{ | |
| 82 | + id: 303, | |
| 83 | + name: "legacy", | |
| 84 | + fullName: "rasmus/legacy", | |
| 85 | + description: "Older snapshot", | |
| 86 | + language: "JavaScript", | |
| 87 | + defaultBranch: "main", | |
| 88 | + headSha: "abc123", | |
| 89 | + files: [{ path: "legacy.js", size: 12, content: "const old = 1;", truncated: false }], | |
| 90 | + commits: [], | |
| 91 | + }], | |
| 92 | + }, | |
| 93 | + }); | |
| 94 | + | |
| 95 | + const page = await harness.agent.get("/s/legacy-share/repositories/303").expect(200); | |
| 96 | + expect(page.text).toContain("legacy.js"); | |
| 97 | + expect(page.text).toContain("<strong>main</strong> default branch"); | |
| 98 | + }); | |
| 99 | +}); |
added tests/releases.test.js +54 −0
| @@ -0,0 +1,54 @@ | ||
| 1 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 2 | +import { createShare, makeHarness } from "./helpers.js"; | |
| 3 | + | |
| 4 | +describe("releases and repository metadata", () => { | |
| 5 | + let harness; | |
| 6 | + afterEach(() => harness.close()); | |
| 7 | + | |
| 8 | + it("shows frozen release lists, notes, tag navigation, and asset metadata", async () => { | |
| 9 | + harness = makeHarness(); | |
| 10 | + const { id } = await createShare(harness); | |
| 11 | + | |
| 12 | + const list = await harness.agent.get(`/s/${id}/repositories/101?tab=releases`).expect(200); | |
| 13 | + expect(list.text).toContain("atlas v1.0.0"); | |
| 14 | + expect(list.text).toContain("Latest"); | |
| 15 | + expect(list.text).toContain("?release=1011"); | |
| 16 | + | |
| 17 | + const detail = await harness.agent.get(`/s/${id}/repositories/101?release=1011`).expect(200); | |
| 18 | + expect(detail.text).toContain("<h2>Highlights</h2>"); | |
| 19 | + expect(detail.text).toContain("atlas-v1.0.0.zip"); | |
| 20 | + expect(detail.text).toContain("2,048 bytes"); | |
| 21 | + expect(detail.text).toContain("not downloadable from this read-only snapshot"); | |
| 22 | + expect(detail.text).toContain("?ref=v1.0.0&refType=tag"); | |
| 23 | + expect(detail.text).not.toContain("download="); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("shows topics, license, language breakdown, and repository statistics", async () => { | |
| 27 | + harness = makeHarness(); | |
| 28 | + const { id } = await createShare(harness); | |
| 29 | + const page = await harness.agent.get(`/s/${id}/repositories/101`).expect(200); | |
| 30 | + | |
| 31 | + expect(page.text).toContain("developer-tools"); | |
| 32 | + expect(page.text).toContain("MIT"); | |
| 33 | + expect(page.text).toContain("<strong>12</strong> stars"); | |
| 34 | + expect(page.text).toContain("JavaScript"); | |
| 35 | + expect(page.text).toContain("70%"); | |
| 36 | + expect(page.text).toContain("https://atlas.test"); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it("finds release notes and assets through snapshot search", async () => { | |
| 40 | + harness = makeHarness(); | |
| 41 | + const { id } = await createShare(harness); | |
| 42 | + const page = await harness.agent | |
| 43 | + .get(`/s/${id}/search?q=Frozen+release+notes&type=releases`) | |
| 44 | + .expect(200); | |
| 45 | + expect(page.text).toContain("atlas v1.0.0"); | |
| 46 | + expect(page.text).toContain(`repositories/101?release=1011`); | |
| 47 | + }); | |
| 48 | + | |
| 49 | + it("rejects release identifiers outside the snapshot", async () => { | |
| 50 | + harness = makeHarness(); | |
| 51 | + const { id } = await createShare(harness); | |
| 52 | + await harness.agent.get(`/s/${id}/repositories/101?release=999`).expect(404); | |
| 53 | + }); | |
| 54 | +}); |
added tests/search.test.js +34 −0
| @@ -0,0 +1,34 @@ | ||
| 1 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 2 | +import { createShare, makeHarness } from "./helpers.js"; | |
| 3 | + | |
| 4 | +describe("snapshot search", () => { | |
| 5 | + let harness; | |
| 6 | + afterEach(() => harness.close()); | |
| 7 | + | |
| 8 | + it("finds code, commits, and repositories without leaving the snapshot", async () => { | |
| 9 | + harness = makeHarness(); | |
| 10 | + const { id } = await createShare(harness, [101, 202]); | |
| 11 | + | |
| 12 | + const code = await harness.agent.get(`/s/${id}/search?q=Project+details`).expect(200); | |
| 13 | + expect(code.text).toContain("docs/guide.md"); | |
| 14 | + expect(code.text).toContain(`repositories/101?file=docs%2Fguide.md&view=source#L3`); | |
| 15 | + expect(code.text).toContain(`repositories/202?file=docs%2Fguide.md&view=source#L3`); | |
| 16 | + | |
| 17 | + const commits = await harness.agent.get(`/s/${id}/search?q=Finish&type=commits`).expect(200); | |
| 18 | + expect(commits.text).toContain("Finish atlas viewer"); | |
| 19 | + expect(commits.text).toContain("Finish ledger viewer"); | |
| 20 | + | |
| 21 | + const repositories = await harness.agent.get(`/s/${id}/search?q=expense&type=repositories`).expect(200); | |
| 22 | + expect(repositories.text).toContain("ledger"); | |
| 23 | + expect(repositories.text).not.toContain(">atlas</a>"); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("can limit results to the current repository", async () => { | |
| 27 | + harness = makeHarness(); | |
| 28 | + const { id } = await createShare(harness, [101, 202]); | |
| 29 | + const page = await harness.agent.get(`/s/${id}/search?q=Snapshot&repository=101`).expect(200); | |
| 30 | + expect(page.text).toContain("Search atlas"); | |
| 31 | + expect(page.text).toContain(`repositories/101?file=README.md&view=source#L3`); | |
| 32 | + expect(page.text).not.toContain(`repositories/202?file=README.md`); | |
| 33 | + }); | |
| 34 | +}); |
added tests/share-management.test.js +81 −0
| @@ -0,0 +1,81 @@ | ||
| 1 | +import request from "supertest"; | |
| 2 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 3 | +import { createShare, makeHarness } from "./helpers.js"; | |
| 4 | + | |
| 5 | +describe("share management", () => { | |
| 6 | + let harness; | |
| 7 | + afterEach(() => harness.close()); | |
| 8 | + | |
| 9 | + it("lists the owner's active snapshot links", async () => { | |
| 10 | + harness = makeHarness(); | |
| 11 | + const { id } = await createShare(harness); | |
| 12 | + | |
| 13 | + const page = await harness.agent.get("/").expect(200); | |
| 14 | + expect(page.text).toContain("Shared snapshots"); | |
| 15 | + expect(page.text).toContain("atlas"); | |
| 16 | + expect(page.text).toContain(`http://profileshare.test/s/${id}`); | |
| 17 | + expect(page.text).toContain(`action="/shares/${id}/revoke"`); | |
| 18 | + expect(page.text).toContain("snapshot-status-active"); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("revokes an owned link and removes its stored content", async () => { | |
| 22 | + harness = makeHarness(); | |
| 23 | + const { id } = await createShare(harness); | |
| 24 | + | |
| 25 | + const response = await harness.agent.post(`/shares/${id}/revoke`).expect(302); | |
| 26 | + expect(response.headers.location).toBe("/?notice=revoked#shared-links"); | |
| 27 | + expect(harness.store.getShare(id)).toMatchObject({ | |
| 28 | + snapshot: null, | |
| 29 | + revoked_at: expect.any(String), | |
| 30 | + }); | |
| 31 | + | |
| 32 | + const publicPage = await request(harness.app).get(`/s/${id}`).expect(410); | |
| 33 | + expect(publicPage.text).toContain("The owner revoked this snapshot link."); | |
| 34 | + expect(publicPage.text).not.toContain("atlas"); | |
| 35 | + | |
| 36 | + const dashboard = await harness.agent.get("/?notice=revoked").expect(200); | |
| 37 | + expect(dashboard.text).toContain("Snapshot content removed"); | |
| 38 | + expect(dashboard.text).toContain("snapshot-status-revoked"); | |
| 39 | + expect(dashboard.text).not.toContain(`action="/shares/${id}/revoke"`); | |
| 40 | + }); | |
| 41 | + | |
| 42 | + it("does not allow anonymous or different owners to revoke a link", async () => { | |
| 43 | + harness = makeHarness(); | |
| 44 | + const { id } = await createShare(harness); | |
| 45 | + | |
| 46 | + await request(harness.app).post(`/shares/${id}/revoke`).expect(401); | |
| 47 | + expect(harness.store.getShare(id).snapshot).not.toBeNull(); | |
| 48 | + | |
| 49 | + harness.store.createShare({ | |
| 50 | + id: "another-owner-share", | |
| 51 | + ownerId: 999, | |
| 52 | + createdAt: "2026-07-23T12:00:00.000Z", | |
| 53 | + expiresAt: "2026-07-30T12:00:00.000Z", | |
| 54 | + snapshot: { profile: {}, repositories: [] }, | |
| 55 | + }); | |
| 56 | + await harness.agent.post("/shares/another-owner-share/revoke").expect(404); | |
| 57 | + expect(harness.store.getShare("another-owner-share").snapshot).not.toBeNull(); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it("shows expired links without active controls", async () => { | |
| 61 | + harness = makeHarness(); | |
| 62 | + const { id } = await createShare(harness, [101], 1); | |
| 63 | + harness.setNow("2026-07-24T12:00:00.000Z"); | |
| 64 | + | |
| 65 | + const page = await harness.agent.get("/").expect(200); | |
| 66 | + expect(page.text).toContain("snapshot-status-expired"); | |
| 67 | + expect(page.text).not.toContain(`action="/shares/${id}/revoke"`); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it("keeps existing links manageable when GitHub is unavailable", async () => { | |
| 71 | + harness = makeHarness(); | |
| 72 | + const { id } = await createShare(harness); | |
| 73 | + harness.github.listRepositories = async () => { | |
| 74 | + throw new Error("network detail"); | |
| 75 | + }; | |
| 76 | + | |
| 77 | + const page = await harness.agent.get("/").expect(200); | |
| 78 | + expect(page.text).toContain("GitHub repositories could not be loaded."); | |
| 79 | + expect(page.text).toContain(`action="/shares/${id}/revoke"`); | |
| 80 | + }); | |
| 81 | +}); |