profileShare

rasmusjy / profileshare

Read-only snapshot

No repository description.

main default branch 54 files Expires Sep 13, 2026, 9:06 AM

Commit

Add GitHub-style snapshot experience

commit bcce357

24 changed files with +2948 and −209

Jump to a changed file
  1. README.md +15 −5
  2. src/app.js +413 −14
  3. src/db.js +59 −4
  4. src/github.js +263 −26
  5. src/public/app.js +61 −3
  6. src/public/styles.css +361 −29
  7. src/views/created.ejs +10 −1
  8. src/views/dashboard.ejs +58 −3
  9. src/views/expired.ejs +8 −3
  10. src/views/profile.ejs +112 −45
  11. src/views/repository.ejs +494 −36
  12. src/views/search.ejs +207 −0
  13. tests/auth.test.js +15 −0
  14. tests/file-browser.test.js +46 −0
  15. tests/github-client.test.js +283 −0
  16. tests/helpers.js +190 −36
  17. tests/issues-pulls.test.js +65 −0
  18. tests/layout.test.js +5 −3
  19. tests/profile-page.test.js +13 −0
  20. tests/read-only.test.js +2 −1
  21. tests/refs.test.js +99 −0
  22. tests/releases.test.js +54 −0
  23. tests/search.test.js +34 −0
  24. 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 %>">&larr; 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>&minus;<%= 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 %> &minus;<%= 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>&minus;<%= 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('/')) : '') %>">&larr; 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('/')) : '' }) %>">&larr; 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">&larr;</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&amp;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&amp;refType=branch&amp;path=src&amp;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 = &#34;review-notes&#34;;');
47 + expect(file.text).toContain("?ref=review-notes&amp;refType=branch&amp;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&amp;refType=branch&amp;file=README.md&amp;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&amp;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&amp;view=source#L3`);
15 + expect(code.text).toContain(`repositories/202?file=docs%2Fguide.md&amp;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&amp;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 +});