From 64c397e256b01a2e542e84529e947e237c07edd6 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 27 Jan 2026 16:58:56 -0500 Subject: Split branch and path bread crumbs into separate section --- cgithub/views/_nav.eta | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 cgithub/views/_nav.eta (limited to 'cgithub/views/_nav.eta') diff --git a/cgithub/views/_nav.eta b/cgithub/views/_nav.eta new file mode 100644 index 0000000..338a6a6 --- /dev/null +++ b/cgithub/views/_nav.eta @@ -0,0 +1,18 @@ +
+
branch <%= it.branch %>
+ <% if (it.path && it.path !== "/") { %> + (top)<% + const pathParts = it.path.split('/').filter(p => p); + let currentPath = ''; + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]; + currentPath += (currentPath ? '/' : '') + part; + const isLast = i === pathParts.length - 1; + if (isLast) { + %>/<%= part %><%= it.isTree ? "/" : "" %><% + } else { + %>/<%= part %><% + } + } %> + <% } %> +
-- cgit v1.3.1 From 6240cf1c6b73b7ed34ff5816b53333382c229f5c Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 21 Jul 2026 17:46:58 -0400 Subject: Add async branch/tag dropdown and latest-commit bar Adds two lazily-fetched UI pieces to match vanilla GitHub while keeping initial page loads plain server-rendered HTML: a branch/tag selector (native , CSS anchor-positioned to its button, light-dismissed via closedby="any") and a latest-commit bar above the file listing. Both are populated by new /api/:owner/:repo/refs/:type and /api/:owner/:repo/latest-commit/:branch/:path routes; the latter calls GitHub's own /latest-commit JSON endpoint (the same one the React UI uses) rather than scraping the full commit history page. --- cgithub/public/static/refs.js | 60 ++++++++++++++ cgithub/public/static/style.css | 62 ++++++++++++++ cgithub/src/app.ts | 46 +++++++++++ cgithub/src/scraper.test.ts | 41 ++++++++++ cgithub/src/scraper.ts | 177 ++++++++++++++++++++++++++++++++++++++-- cgithub/views/_latestCommit.eta | 16 ++++ cgithub/views/_nav.eta | 11 ++- cgithub/views/_refs.eta | 17 ++++ cgithub/views/_tree.eta | 3 + cgithub/views/layout.eta | 1 + 10 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 cgithub/public/static/refs.js create mode 100644 cgithub/views/_latestCommit.eta create mode 100644 cgithub/views/_refs.eta (limited to 'cgithub/views/_nav.eta') diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js new file mode 100644 index 0000000..28b8373 --- /dev/null +++ b/cgithub/public/static/refs.js @@ -0,0 +1,60 @@ +// Progressive enhancement for two bits of data GitHub itself loads lazily: +// the branch/tag dropdown and the latest-commit bar above a file listing. +// Both degrade gracefully without JS: the button's command/commandfor still +// opens the (empty) dialog natively; the commit placeholder just never fills +// in. +document.addEventListener("DOMContentLoaded", () => { + const dialog = document.querySelector(".ref-selector-menu"); + if (dialog) { + const list = dialog.querySelector(".ref-selector-list"); + const { owner, repo } = dialog.dataset; + const cache = {}; + + async function load(type) { + if (Object.prototype.hasOwnProperty.call(cache, type)) { + list.innerHTML = cache[type]; + return; + } + list.textContent = "Loading…"; + try { + const res = await fetch(`/api/${owner}/${repo}/refs/${type}`); + const html = res.ok ? await res.text() : ""; + cache[type] = html || "Failed to load."; + list.innerHTML = cache[type]; + } catch { + list.textContent = "Failed to load."; + } + } + + let opened = false; + dialog.addEventListener("toggle", () => { + if (dialog.open && !opened) { + opened = true; + load("branches"); + } + }); + + for (const tab of dialog.querySelectorAll("[data-ref-type]")) { + tab.addEventListener("click", () => { + for (const other of dialog.querySelectorAll("[data-ref-type]")) { + other.classList.toggle("active", other === tab); + } + load(tab.dataset.refType); + }); + } + } + + const commitPlaceholder = document.querySelector(".latest-commit-placeholder"); + if (commitPlaceholder) { + fetch(commitPlaceholder.dataset.src) + .then((res) => (res.ok ? res.text() : "")) + .then((html) => { + if (html.trim()) { + commitPlaceholder.outerHTML = html; + } else { + commitPlaceholder.remove(); + } + }) + .catch(() => commitPlaceholder.remove()); + } +}); diff --git a/cgithub/public/static/style.css b/cgithub/public/static/style.css index e698993..983cb86 100644 --- a/cgithub/public/static/style.css +++ b/cgithub/public/static/style.css @@ -160,6 +160,68 @@ body > footer { border: 1px solid var(--border-color); } +.ref-selector-button { + anchor-name: --ref-selector-anchor; +} + +.ref-selector-menu { + position: absolute; + position-anchor: --ref-selector-anchor; + position-area: bottom span-right; + width: 16rem; + max-height: 20rem; + overflow-y: auto; + overflow-wrap: break-word; + background: var(--body-background); + border: 1px solid var(--border-color); + padding: 0.5rem; + + &::backdrop { + background: transparent; + } +} + +.ref-selector-tabs { + display: flex; + margin-bottom: 0.5rem; + + button { + flex: 1; + padding: 0.25rem; + cursor: pointer; + &.active { + font-weight: bold; + border-bottom: 2px solid var(--link-color); + } + } +} + +.ref-selector-items { + list-style: none; + margin: 0; + padding: 0; + + li { + padding: 0.2rem 0; + } +} + +.ref-selector-badge { + margin-left: 0.3rem; + font-size: 0.75rem; + opacity: 0.7; +} + +.ref-selector-empty, +.ref-selector-more { + padding: 0.2rem 0; + font-size: 0.85rem; +} + +.latest-commit-placeholder { + opacity: 0.7; +} + .repo-info { .description { margin-bottom: .2rem; diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index 78b59ae..ca79b1f 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -5,8 +5,10 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, + getGitHubRefs, getGitHubRelease, getGitHubReleases, getGitHubRepo, @@ -44,6 +46,29 @@ export function createApp(eta: Eta) { return c.html(eta.render(template, data)); } + // For fragments fetched asynchronously by client-side JS (see public/static/refs.js): + // errors render as an empty body rather than the full error.eta page. + async function tryRenderFragment( + c: Context, + template: string, + promise: Promise, + ) { + let data: T; + try { + data = await promise; + } catch (e) { + const status = + e instanceof GitHubHTTPError + ? (e.status as StatusCode) + : e instanceof RedirectError + ? 404 + : 500; + c.status(status); + return c.body(null); + } + return c.html(eta.render(template, data)); + } + app.get("/:owner", async (c) => { const { owner } = c.req.param(); return tryRender(c, "owner.eta", getGitHubOwner(owner)); @@ -112,6 +137,27 @@ export function createApp(eta: Eta) { return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); }); + // Internal endpoints fetched asynchronously by client-side JS, not meant to + // be visited directly. Keep the site's initial page loads plain server- + // rendered HTML while still surfacing data (branches/tags, latest commit) + // that would otherwise require an extra scrape on every page view. + app.get("/api/:owner/:repo/refs/:type", async (c) => { + const { owner, repo, type } = c.req.param(); + if (type !== "branches" && type !== "tags") { + return c.notFound(); + } + return tryRenderFragment(c, "_refs.eta", getGitHubRefs(owner, repo, type)); + }); + + app.get("/api/:owner/:repo/latest-commit/:branch/:path{.*}?", async (c) => { + const { owner, repo, branch, path = "" } = c.req.param(); + return tryRenderFragment( + c, + "_latestCommit.eta", + getGitHubLatestCommit(owner, repo, branch, path), + ); + }); + app.get("/:owner/:repo/releases/tag/:tag", async (c) => { const { owner, repo, tag } = c.req.param(); return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index f9b60e8..56f14b0 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,7 +4,9 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLatestCommit, getGitHubPulls, + getGitHubRefs, getGitHubRelease, getGitHubReleases, getGitHubRepo, @@ -205,6 +207,45 @@ describe("GitHub scraper", () => { assert.ok(firstRelease.bodyHtml.length > 0); }); + it("should fetch latest commit for a path", async () => { + const data = await getGitHubLatestCommit("actions", "deploy-pages", "main", "src"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.ok(data.commit); + assert.strictEqual(data.commit.oid.length, 40); + assert.ok(data.commit.shortMessageHtml.length > 0); + assert.ok(data.commit.date.length > 0); + assert.ok(data.commit.authors.length > 0); + }); + + it("should fetch branches", async () => { + const data = await getGitHubRefs("actions", "deploy-pages", "branches"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.type, "branches"); + + assert.ok(data.refs.length > 0); + const main = data.refs.find((ref) => ref.name === "main"); + assert.ok(main); + assert.strictEqual(main.isDefault, true); + }); + + it("should fetch tags", async () => { + const data = await getGitHubRefs("actions", "deploy-pages", "tags"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.type, "tags"); + + assert.ok(data.refs.length > 0); + const tag = data.refs.find((ref) => ref.name === "v5.0.0"); + assert.ok(tag); + assert.ok(tag.date?.length); + assert.strictEqual(data.hasMore, true); + }); + it("should fetch single release", async () => { const data = await getGitHubRelease("actions", "deploy-pages", "v4.0.5"); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 53f2bb1..39ad2cd 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -70,7 +70,7 @@ interface IssueIndexPageQuery { type StylingDirective = [number, number, string]; -interface Commit { +export interface Commit { oid: string; url: string; authoredDate: string; @@ -270,6 +270,34 @@ export interface GitHubCommits extends GitHubNav { commitGroups: CommitGroup[]; } +export interface LatestCommit { + oid: string; + date: string; + shortMessageHtml: string; + authors: { + login: string; + displayName: string; + avatarUrl: string; + }[]; +} + +export interface GitHubLatestCommit extends GitHubCommon { + commit: LatestCommit; +} + +export interface Ref { + name: string; + isDefault?: boolean; + date?: string; + authorLogin?: string; +} + +export interface GitHubRefs extends GitHubCommon { + type: "branches" | "tags"; + refs: Ref[]; + hasMore: boolean; +} + interface Release { tagName: string; title: string; @@ -325,13 +353,13 @@ export interface GitHubOwner { repos: OwnerRepo[]; } -async function fetchGitHubPage(path: string): Promise { +async function fetchGitHubResponse(path: string, accept: string): Promise { // GitHub throttles/blocks requests without realistic browser headers. // These headers make the request appear as a standard browser visit. const headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + Accept: accept, "Accept-Language": "en-US,en;q=0.5", }; const response = await fetch(`https://github.com/${path}`, { headers, redirect: "manual" }); @@ -345,10 +373,25 @@ async function fetchGitHubPage(path: string): Promise { } } if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText); + return response; +} + +async function fetchGitHubPage(path: string): Promise { + const response = await fetchGitHubResponse( + path, + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + ); return response.text(); } -function parsePayload(html: string): AppPayload | null { +// Some routes (e.g. latest-commit) are the same endpoints the React UI itself +// calls to lazily fill in data; they 406 without an explicit JSON Accept. +async function fetchGitHubJson(path: string): Promise { + const response = await fetchGitHubResponse(path, "application/json"); + return (await response.json()) as T; +} + +function parseEmbeddedPayload(html: string): T | null { const regex = new RegExp( ``, "g", @@ -367,6 +410,10 @@ function parsePayload(html: string): AppPayload | null { return null; } +function parsePayload(html: string): AppPayload | null { + return parseEmbeddedPayload(html); +} + function parseRepoInfo(html: string): RepoInfo { const document = parseDocument(html); @@ -639,6 +686,121 @@ export async function getGitHubCommits( }; } +interface LatestCommitPayload { + oid: string; + date: string; + shortMessageHtmlLink: string; + authors: { + login: string; + displayName: string; + avatarUrl: string; + }[]; +} + +// This is the same endpoint the React UI itself calls to lazily fill in the +// commit bar above a file listing, rather than the much heavier /commits +// history page: it returns just the single latest commit as JSON. +export async function getGitHubLatestCommit( + owner: string, + repo: string, + branch: string, + path: string, +): Promise { + const urlPath = path + ? `${owner}/${repo}/latest-commit/${branch}/${path}` + : `${owner}/${repo}/latest-commit/${branch}`; + const payload = await fetchGitHubJson(urlPath); + + return { + repo: { owner, name: repo }, + commit: { + oid: payload.oid, + date: payload.date, + shortMessageHtml: payload.shortMessageHtmlLink, + authors: payload.authors, + }, + }; +} + +interface BranchRefEntry { + name: string; + isDefault: boolean; + authoredDate?: string; + author?: { login: string }; +} + +interface BranchesPayload { + branches: { + default?: BranchRefEntry; + yours?: BranchRefEntry[]; + active?: BranchRefEntry[]; + stale?: BranchRefEntry[]; + }; + hasMore?: Record; +} + +async function getGitHubBranchRefs(owner: string, repo: string): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/branches`); + const payload = parseEmbeddedPayload(html); + + if (payload?.branches === undefined) { + throw new Error("Could not find branches data in embedded JSON"); + } + + const { default: defaultBranch, yours = [], active = [], stale = [] } = payload.branches; + const entries = [...(defaultBranch ? [defaultBranch] : []), ...yours, ...active, ...stale]; + + const seen = new Set(); + const refs: Ref[] = []; + for (const entry of entries) { + if (seen.has(entry.name)) continue; + seen.add(entry.name); + refs.push({ + name: entry.name, + isDefault: entry.isDefault || undefined, + date: entry.authoredDate, + authorLogin: entry.author?.login, + }); + } + + const hasMore = Object.values(payload.hasMore ?? {}).some(Boolean); + + return { repo: { owner, name: repo }, type: "branches", refs, hasMore }; +} + +async function getGitHubTagRefs(owner: string, repo: string): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/tags`); + const document = parseDocument(html); + + const refs: Ref[] = []; + for (const row of cssSelect.selectAll(".Box-row", document) as unknown as Element[]) { + const nameElem = cssSelect.selectOne("h2 a", row); + if (!nameElem) continue; + const name = textContent(nameElem).trim(); + if (!name) continue; + + // The row has two elements: a release/tag creation + // timestamp, then the tagged commit's authored date. We want the latter. + const timeElems = cssSelect.selectAll("relative-time", row); + const timeElem = timeElems[1] as Element | undefined; + const date = timeElem ? getAttributeValue(timeElem, "datetime") || undefined : undefined; + + refs.push({ name, date }); + } + + const hasMore = cssSelect.selectOne(".pagination a", document) !== null; + + return { repo: { owner, name: repo }, type: "tags", refs, hasMore }; +} + +export async function getGitHubRefs( + owner: string, + repo: string, + type: "branches" | "tags", +): Promise { + return type === "branches" ? getGitHubBranchRefs(owner, repo) : getGitHubTagRefs(owner, repo); +} + function parseRelease(section: Element, title: string): Release { // Extract tag name from link const tagLink = cssSelect.selectOne('a[href*="/tree/"]', section); @@ -867,9 +1029,10 @@ function parseUser(document: AnyNode, login: string): GitHubOwner { } function parseOrganization(document: AnyNode, owner: string): GitHubOwner { - const avatarElem = cssSelect.selectOne(".orghead img[itemprop='image']", document) as - | Element - | null; + const avatarElem = cssSelect.selectOne( + ".orghead img[itemprop='image']", + document, + ) as Element | null; const avatarUrl = avatarElem ? getAttributeValue(avatarElem, "src") || null : null; // The login only appears in the avatar's "@login" alt text. const alt = avatarElem ? getAttributeValue(avatarElem, "alt") : undefined; diff --git a/cgithub/views/_latestCommit.eta b/cgithub/views/_latestCommit.eta new file mode 100644 index 0000000..18d22a1 --- /dev/null +++ b/cgithub/views/_latestCommit.eta @@ -0,0 +1,16 @@ +
+
+
+ <%~ it.commit.shortMessageHtml %> +
+
+ <% for (const author of it.commit.authors) { %> + <%= author.login %> + <% } %> + +
+
+
+ <%= it.commit.oid.substring(0, 7) %> +
+
diff --git a/cgithub/views/_nav.eta b/cgithub/views/_nav.eta index 338a6a6..e3fbdcf 100644 --- a/cgithub/views/_nav.eta +++ b/cgithub/views/_nav.eta @@ -1,5 +1,14 @@
-
branch <%= it.branch %>
+ + +
+ + +
+
Loading…
+
<% if (it.path && it.path !== "/") { %> (top)<% const pathParts = it.path.split('/').filter(p => p); diff --git a/cgithub/views/_refs.eta b/cgithub/views/_refs.eta new file mode 100644 index 0000000..2450c4b --- /dev/null +++ b/cgithub/views/_refs.eta @@ -0,0 +1,17 @@ +<% if (it.refs.length === 0) { %> +
No <%= it.type %> found.
+<% } else { %> +
    + <% for (const ref of it.refs) { %> +
  • + <%= ref.name %> + <% if (ref.isDefault) { %>default<% } %> +
  • + <% } %> +
+<% } %> +<% if (it.hasMore) { %> + +<% } %> diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta index ed6eee9..a0ecfab 100644 --- a/cgithub/views/_tree.eta +++ b/cgithub/views/_tree.eta @@ -1,5 +1,8 @@ <%~ include("./_nav.eta", {...it, isTree: true}) %> +<% const commitPath = it.path && it.path !== "/" ? it.path : ""; %> +
Loading latest commit…
+ <% for (const item of it.items) { %> diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index dd523d1..a80bd1d 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -5,6 +5,7 @@ <%= it.repo.owner %>/<%= it.repo.name %> - <%= it.path || '/' %> +
-- cgit v1.3.1 From 5526d976f645def4caae9fbd2c1d75349029764e Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 11 Aug 2026 08:25:08 -0400 Subject: Abbreviate commit hashes in branch picker --- cgithub/views/_nav.eta | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'cgithub/views/_nav.eta') diff --git a/cgithub/views/_nav.eta b/cgithub/views/_nav.eta index e3fbdcf..eb7ed42 100644 --- a/cgithub/views/_nav.eta +++ b/cgithub/views/_nav.eta @@ -1,6 +1,10 @@
+ <% + const isCommitHash = /^[0-9a-f]{40}$/.test(it.branch); + const refLabel = isCommitHash ? it.branch.slice(0, 7) : it.branch; + %>
-- cgit v1.3.1 From 866f99c414cb3d102483ce21c68a0e62b394baaa Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 11 Aug 2026 13:32:44 -0400 Subject: Keep the ref picker on the current view and path --- cgithub/public/static/refs.js | 6 +++-- cgithub/src/app.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++- cgithub/src/app.ts | 12 +++++++--- cgithub/views/_nav.eta | 4 ++-- cgithub/views/_refs.eta | 6 ++++- cgithub/views/_tree.eta | 2 +- cgithub/views/blob.eta | 2 +- cgithub/views/commits.eta | 2 +- 8 files changed, 74 insertions(+), 12 deletions(-) (limited to 'cgithub/views/_nav.eta') diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js index ffff505..f3e07e8 100644 --- a/cgithub/public/static/refs.js +++ b/cgithub/public/static/refs.js @@ -7,7 +7,9 @@ document.addEventListener("DOMContentLoaded", () => { const dialog = document.querySelector(".ref-selector-menu"); if (dialog) { const list = dialog.querySelector(".ref-selector-list"); - const { owner, repo } = dialog.dataset; + // The view and path let the picker link to the same place on another ref + // rather than to the repository root. + const { owner, repo, view, path = "" } = dialog.dataset; const cache = {}; async function load(type) { @@ -17,7 +19,7 @@ document.addEventListener("DOMContentLoaded", () => { } list.textContent = "Loading…"; try { - const res = await fetch(`/api/${owner}/${repo}/refs/${type}`); + const res = await fetch(`/api/${owner}/${repo}/refs/${type}/${view}/${path}`); const html = res.ok ? await res.text() : ""; cache[type] = html || "Failed to load."; list.innerHTML = cache[type]; diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts index 498d8a6..6544f03 100644 --- a/cgithub/src/app.test.ts +++ b/cgithub/src/app.test.ts @@ -8,6 +8,56 @@ import { createApp } from "./app.ts"; const eta = new Eta({ views: path.join(import.meta.dirname, "..", "views") }); const app = createApp(eta); +describe("branch picker", () => { + const repo = { owner: "actions", name: "deploy-pages" }; + const refs = [{ name: "main", isDefault: true }, { name: "v4" }]; + + it("should keep the current view and path", () => { + const blob = eta.render("_refs.eta", { + repo, + refs, + type: "branches", + view: "blob", + path: "src/main.ts", + }); + const tree = eta.render("_refs.eta", { + repo, + refs, + type: "branches", + view: "tree", + path: "src", + }); + + assert.match(blob, /href="\/actions\/deploy-pages\/blob\/main\/src\/main.ts"/); + assert.match(blob, /href="\/actions\/deploy-pages\/blob\/v4\/src\/main.ts"/); + assert.match(tree, /href="\/actions\/deploy-pages\/tree\/v4\/src"/); + }); + + it("should link to the root tree without a path", () => { + const html = eta.render("_refs.eta", { repo, refs, type: "branches", view: "tree", path: "" }); + + assert.match(html, /href="\/actions\/deploy-pages\/tree\/main"/); + }); + + it("should not build ref links for views it does not serve", async () => { + const res = await app.request("http://cgithub.example/api/a/b/refs/branches/raw/README.md"); + + assert.strictEqual(res.status, 404); + }); + + it("should tell the refs endpoint where the picker was opened", () => { + const nav = { repo, branch: "main", path: "src" }; + + const blob = eta.render("_nav.eta", { ...nav, view: "blob", path: "src/main.ts" }); + assert.match(blob, /data-view="blob" data-path="src\/main.ts"/); + assert.match( + eta.render("_nav.eta", { ...nav, view: "tree" }), + /data-view="tree" data-path="src"/, + ); + assert.doesNotMatch(eta.render("_nav.eta", { ...nav, view: "tree", path: "" }), /data-path=/); + }); +}); + describe("redirects to GitHub", () => { // An HTTP redirect would be caught by the extension's declarativeNetRequest // rules and bounced straight back here. @@ -21,7 +71,7 @@ describe("redirects to GitHub", () => { const body = await res.text(); assert.match( body, - //, + //, ); assert.doesNotMatch(body, /