From a5d7b8593273e7a6f5094ce35a9b3e29bb3ffa57 Mon Sep 17 00:00:00 2001 From: dongguacute Date: Wed, 19 Aug 2026 01:22:30 +0800 Subject: [PATCH 1/5] Refactor root location detection and improve LOC fetching logic - Updated the `locateRoot` function to use a MutationObserver for dynamic DOM changes, enhancing the reliability of root element detection. - Modified the `loadLoc` function to improve type safety and URL construction for fetching LOC data, including support for custom branch names with slashes. - Updated the link in the `Stat` component to point to the new domain for the application. --- src/stat/Stat.tsx | 2 +- src/stat/injector.ts | 52 +++++++++++++++++++++++++++++--------------- src/stat/loader.ts | 44 ++++++++++++++----------------------- 3 files changed, 52 insertions(+), 46 deletions(-) diff --git a/src/stat/Stat.tsx b/src/stat/Stat.tsx index 8c1b382..40499b2 100644 --- a/src/stat/Stat.tsx +++ b/src/stat/Stat.tsx @@ -8,7 +8,7 @@ export default function Stat({ org, repo, branch }: Props) { return ( { return new Promise((resolve) => { - const root = document.evaluate( - '//h2[text()="About" and not(@class="heading-element")]', - document, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null, - ).singleNodeValue?.parentElement - - const repoVisibility = document.evaluate( - '//*[@id="repo-title-component"]/span[2]', - document, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null, - ).singleNodeValue - - if (root && !isInjected(root)) { + let observer: MutationObserver | undefined + + const tryLocate = () => { + const root = document.evaluate( + '//h2[normalize-space(.)="About" and not(@class="heading-element")]', + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue?.parentElement + + if (!root) { + return false + } + + if (isInjected(root)) { + observer?.disconnect() + return true + } + + const repoVisibility = document.evaluate( + '//*[@id="repo-title-component"]/span[2]', + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue + + observer?.disconnect() resolve([root, repoVisibility?.textContent !== "Private"]) + return true + } + + if (!tryLocate()) { + observer = new MutationObserver(tryLocate) + observer.observe(document.documentElement, { childList: true, subtree: true }) } }) } diff --git a/src/stat/loader.ts b/src/stat/loader.ts index 3e27bce..7e64ef0 100644 --- a/src/stat/loader.ts +++ b/src/stat/loader.ts @@ -1,3 +1,5 @@ +/// + import { now } from "./util" export interface LocData { @@ -10,20 +12,11 @@ function makeKey(org: string, repo: string, branch: string) { return org + "/" + repo + "/" + branch } -async function sha1(str: string) { - const encoder = new TextEncoder() - const hash = await crypto.subtle.digest("SHA-1", encoder.encode(str)) - - return Array.from(new Uint8Array(hash)) - .map((v) => v.toString(16).padStart(2, "0")) - .join("") -} - export function loadLoc(org: string, repo: string, branch: string): Promise { return new Promise((resolve) => { const key = makeKey(org, repo, branch) - chrome.storage.local.get(key, (data) => { + chrome.storage.local.get(key, (data: { [key: string]: unknown }) => { const locData = data[key] as LocData if ( @@ -41,30 +34,25 @@ export function loadLoc(org: string, repo: string, branch: string): Promise { - let url = `https://ghloc-api.vercel.app/${org}/${repo}/${branch}` - - const accessToken = await chrome.storage.sync.get("accessToken") + const encodedBranch = branch.split("/").map(encodeURIComponent).join("/") + const params = new URLSearchParams({ pretty: "false" }) const ignoredFiles = await chrome.storage.sync.get("ignoredFiles") - const headers = new Headers({ - "Ghloc-Authorization": import.meta.env.VITE_AUTH_TOKEN, - }) - if (Array.isArray(ignoredFiles.ignoredFiles) && ignoredFiles.ignoredFiles.length > 0) { - url += "?match=" - - for (const ignored of ignoredFiles.ignoredFiles) { - url += "!" + ignored + "$," - } - - url = url.substring(0, url.length - 1) + params.set( + "filter", + (ignoredFiles.ignoredFiles as string[]).map((ignored: string) => `!${ignored}$`).join(","), + ) } - if (typeof accessToken.accessToken === "string" && accessToken.accessToken.length > 0) { - headers.append("Authorization", `Bearer ${accessToken.accessToken}`) + const url = `https://ghloc.ifels.dev/${encodeURIComponent(org)}/${encodeURIComponent( + repo, + )}/${encodedBranch}?${params}` + const headers = new Headers() + const authToken = import.meta.env.VITE_AUTH_TOKEN - url += url.includes("?match") ? "&" : "?" - url += "salt=" + (await sha1(accessToken.accessToken)) + if (typeof authToken === "string" && authToken.length > 0) { + headers.set("Ghloc-Authorization", authToken) } let data: LocData = await fetch(url, { headers }) From ef2b7c8ff3f1a08ba3a48f375d528d4b7750079a Mon Sep 17 00:00:00 2001 From: dongguacute Date: Wed, 19 Aug 2026 01:29:51 +0800 Subject: [PATCH 2/5] Refactor URL construction and branch handling in LOC fetching - Updated the URL construction in the `fetchLoc` function to handle optional branch paths more cleanly. - Modified the `Stat` component's link to conditionally include the branch parameter based on its presence. - Improved branch detection logic in the `getTarget` function to utilize more reliable DOM selectors. --- src/stat/Stat.tsx | 4 +++- src/stat/loader.ts | 7 ++++--- src/stat/util.ts | 17 +++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/stat/Stat.tsx b/src/stat/Stat.tsx index 40499b2..eaa2c0a 100644 --- a/src/stat/Stat.tsx +++ b/src/stat/Stat.tsx @@ -8,7 +8,9 @@ export default function Stat({ org, repo, branch }: Props) { return ( ("#ref-picker-repos-header-ref-selector") ?? + document.querySelector('[data-testid="anchor-button"][aria-label$=" branch"]') + const ariaLabel = branchSelector?.getAttribute("aria-label") + + branch = ariaLabel?.replace(/\s+branch$/i, "").trim() || branchSelector?.textContent?.trim() } - return [path[1], path[2], branch || "main"] + return [path[1], path[2], branch || ""] } export function getFilter(): Promise { From c5792f31afe434c699462cb9ef3821d6c10b9423 Mon Sep 17 00:00:00 2001 From: dongguacute Date: Wed, 19 Aug 2026 01:35:45 +0800 Subject: [PATCH 3/5] Enhance LOC fetching and repository visibility detection - Updated the `fetchLoc` function to accept an `isPublic` parameter, allowing for conditional API URL selection based on repository visibility. - Introduced a new `isPublicRepository` function to determine repository visibility more reliably. - Refactored the `locateRoot` function to utilize the new visibility detection logic, improving the overall robustness of the LOC fetching process. --- src/stat/index.ts | 2 +- src/stat/injector.ts | 29 ++++++++++++++++++++--------- src/stat/loader.ts | 40 ++++++++++++++++++++++++++++++++++++---- src/stat/util.ts | 24 +++++++++++++----------- 4 files changed, 70 insertions(+), 25 deletions(-) diff --git a/src/stat/index.ts b/src/stat/index.ts index dbaa42c..eb68e67 100644 --- a/src/stat/index.ts +++ b/src/stat/index.ts @@ -36,7 +36,7 @@ function main() { } } - fetchLoc(org, repo, branch) + fetchLoc(org, repo, branch, isPublic) .then((locData) => { updateStat(stat, locData.loc) diff --git a/src/stat/injector.ts b/src/stat/injector.ts index fe92a22..c8b93bb 100644 --- a/src/stat/injector.ts +++ b/src/stat/injector.ts @@ -6,6 +6,25 @@ function isInjected(root: Element) { return root.querySelector("#github-loc") !== null } +function isPublicRepository() { + const publicMeta = document.querySelector('meta[name="octolytics-dimension-repository_public"]') + const publicValue = publicMeta?.getAttribute("content") + + if (publicValue !== null) { + return publicValue === "true" + } + + const repoVisibility = document.evaluate( + '//*[@id="repo-title-component"]/span[2]', + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue + + return repoVisibility?.textContent !== "Private" +} + export function locateRoot(): Promise<[Element, boolean]> { return new Promise((resolve) => { let observer: MutationObserver | undefined @@ -28,16 +47,8 @@ export function locateRoot(): Promise<[Element, boolean]> { return true } - const repoVisibility = document.evaluate( - '//*[@id="repo-title-component"]/span[2]', - document, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null, - ).singleNodeValue - observer?.disconnect() - resolve([root, repoVisibility?.textContent !== "Private"]) + resolve([root, isPublicRepository()]) return true } diff --git a/src/stat/loader.ts b/src/stat/loader.ts index 13e99c3..7480d7e 100644 --- a/src/stat/loader.ts +++ b/src/stat/loader.ts @@ -12,6 +12,15 @@ function makeKey(org: string, repo: string, branch: string) { return org + "/" + repo + "/" + branch } +async function sha1(str: string) { + const encoder = new TextEncoder() + const hash = await crypto.subtle.digest("SHA-1", encoder.encode(str)) + + return Array.from(new Uint8Array(hash)) + .map((v) => v.toString(16).padStart(2, "0")) + .join("") +} + export function loadLoc(org: string, repo: string, branch: string): Promise { return new Promise((resolve) => { const key = makeKey(org, repo, branch) @@ -33,9 +42,15 @@ export function loadLoc(org: string, repo: string, branch: string): Promise { +export async function fetchLoc( + org: string, + repo: string, + branch: string, + isPublic: boolean, +): Promise { const encodedBranch = branch.split("/").map(encodeURIComponent).join("/") const params = new URLSearchParams({ pretty: "false" }) + const accessToken = await chrome.storage.sync.get("accessToken") const ignoredFiles = await chrome.storage.sync.get("ignoredFiles") if (Array.isArray(ignoredFiles.ignoredFiles) && ignoredFiles.ignoredFiles.length > 0) { @@ -46,9 +61,13 @@ export async function fetchLoc(org: string, repo: string, branch: string): Promi } const branchPath = encodedBranch ? `/${encodedBranch}` : "" - const url = `https://ghloc.ifels.dev/${encodeURIComponent( - org, - )}/${encodeURIComponent(repo)}${branchPath}?${params}` + const configuredApiUrl = import.meta.env.VITE_API_URL + const apiBaseUrl = + typeof configuredApiUrl === "string" && configuredApiUrl.length > 0 + ? configuredApiUrl.replace(/\/$/, "") + : isPublic + ? "https://ghloc.ifels.dev" + : "https://ghloc-api.vercel.app" const headers = new Headers() const authToken = import.meta.env.VITE_AUTH_TOKEN @@ -56,6 +75,19 @@ export async function fetchLoc(org: string, repo: string, branch: string): Promi headers.set("Ghloc-Authorization", authToken) } + if ( + !isPublic && + typeof accessToken.accessToken === "string" && + accessToken.accessToken.length > 0 + ) { + headers.set("Authorization", `Bearer ${accessToken.accessToken}`) + params.set("salt", await sha1(accessToken.accessToken)) + } + + const url = `${apiBaseUrl}/${encodeURIComponent( + org, + )}/${encodeURIComponent(repo)}${branchPath}?${params}` + let data: LocData = await fetch(url, { headers }) .then((res) => res.json()) .then((data) => { diff --git a/src/stat/util.ts b/src/stat/util.ts index b0b4824..58b1b7d 100644 --- a/src/stat/util.ts +++ b/src/stat/util.ts @@ -5,20 +5,22 @@ export function now(): number { return Math.floor(Date.now() / 1000) } -export function getTarget() { - const path = window.location.pathname.split("/") - let branch: string | undefined = path.slice(4).join("/") +function getBranchFromSelector() { + const branchSelector = + document.querySelector("#ref-picker-repos-header-ref-selector") ?? + document.querySelector('[data-testid="anchor-button"][aria-label$=" branch"]') + const ariaLabel = branchSelector?.getAttribute("aria-label") - if (!branch) { - const branchSelector = - document.querySelector("#ref-picker-repos-header-ref-selector") ?? - document.querySelector('[data-testid="anchor-button"][aria-label$=" branch"]') - const ariaLabel = branchSelector?.getAttribute("aria-label") + return ariaLabel?.replace(/\s+branch$/i, "").trim() || branchSelector?.textContent?.trim() +} - branch = ariaLabel?.replace(/\s+branch$/i, "").trim() || branchSelector?.textContent?.trim() - } +export function getTarget() { + const path = window.location.pathname.split("/") + const branchFromPath = + path[3] === "tree" || path[3] === "blob" ? path.slice(4).join("/") : undefined + const branch = getBranchFromSelector() || branchFromPath || "" - return [path[1], path[2], branch || ""] + return [path[1], path[2], branch] } export function getFilter(): Promise { From 56e982946c2c7446693f0e375eab46662e239dbb Mon Sep 17 00:00:00 2001 From: dongguacute Date: Wed, 19 Aug 2026 01:41:39 +0800 Subject: [PATCH 4/5] Implement enhanced GitHub LOC fetching with improved error handling and file filtering - Introduced new interfaces for GitHub API responses to improve type safety. - Refactored the `fetchLocalLoc` and `fetchPublicLoc` functions to streamline LOC data retrieval and incorporate ignored file filtering. - Added utility functions for decoding base64 content and counting text lines, enhancing the overall robustness of the LOC fetching process. - Improved error handling for GitHub API requests to provide clearer feedback on failures. --- src/stat/loader.ts | 236 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 181 insertions(+), 55 deletions(-) diff --git a/src/stat/loader.ts b/src/stat/loader.ts index 7480d7e..41817ef 100644 --- a/src/stat/loader.ts +++ b/src/stat/loader.ts @@ -8,56 +8,163 @@ export interface LocData { lastFetched: number } +interface GitHubTreeEntry { + path: string + sha: string + type: string +} + +interface GitHubTreeResponse { + tree: GitHubTreeEntry[] + truncated: boolean +} + +interface GitHubBlobResponse { + content: string + encoding: string +} + +interface GitHubRepositoryResponse { + default_branch: string +} + +const GITHUB_API = "https://api.github.com" +const BLOB_BATCH_SIZE = 10 + function makeKey(org: string, repo: string, branch: string) { return org + "/" + repo + "/" + branch } -async function sha1(str: string) { - const encoder = new TextEncoder() - const hash = await crypto.subtle.digest("SHA-1", encoder.encode(str)) +async function fetchGitHubJson(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.message || `GitHub API request failed (${response.status})`) + } - return Array.from(new Uint8Array(hash)) - .map((v) => v.toString(16).padStart(2, "0")) - .join("") + return data as T } -export function loadLoc(org: string, repo: string, branch: string): Promise { - return new Promise((resolve) => { - const key = makeKey(org, repo, branch) +function decodeBase64(value: string) { + const binary = atob(value.replace(/\s/g, "")) + const bytes = new Uint8Array(binary.length) - chrome.storage.local.get(key, (data: { [key: string]: unknown }) => { - const locData = data[key] as LocData + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index) + } - if ( - typeof locData === "object" && - typeof locData.loc === "number" && - typeof locData.locByLangs === "object" && - typeof locData.lastFetched === "number" - ) { - resolve(locData as LocData) - } else { - resolve(null) - } - }) - }) + return bytes } -export async function fetchLoc( +function countTextLines(bytes: Uint8Array) { + if (bytes.includes(0)) { + return 0 + } + + const text = new TextDecoder().decode(bytes) + + if (text.length === 0) { + return 0 + } + + const lines = text.split(/\r\n|\r|\n/) + return lines.length - (/(?:\r\n|\r|\n)$/.test(text) ? 1 : 0) +} + +function getFileExtension(path: string) { + const fileName = path.split("/").pop() || path + const dotIndex = fileName.lastIndexOf(".") + + return dotIndex > 0 ? fileName.slice(dotIndex).toLowerCase() : fileName +} + +function isIgnored(path: string, ignoredFiles: string[]) { + return ignoredFiles.some((ignored) => path.toLowerCase().endsWith(ignored.toLowerCase())) +} + +async function fetchLocalLoc( org: string, repo: string, branch: string, - isPublic: boolean, + ignoredFiles: string[], ): Promise { - const encodedBranch = branch.split("/").map(encodeURIComponent).join("/") - const params = new URLSearchParams({ pretty: "false" }) const accessToken = await chrome.storage.sync.get("accessToken") - const ignoredFiles = await chrome.storage.sync.get("ignoredFiles") - if (Array.isArray(ignoredFiles.ignoredFiles) && ignoredFiles.ignoredFiles.length > 0) { - params.set( - "filter", - (ignoredFiles.ignoredFiles as string[]).map((ignored: string) => `!${ignored}$`).join(","), + if (typeof accessToken.accessToken !== "string" || accessToken.accessToken.length === 0) { + throw new Error("A GitHub access token is required for private repositories") + } + + const token = accessToken.accessToken + const repository = branch + ? null + : await fetchGitHubJson( + `${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent(repo)}`, + token, + ) + const ref = branch || repository!.default_branch + const tree = await fetchGitHubJson( + `${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent( + repo, + )}/git/trees/${encodeURIComponent(ref)}?recursive=1`, + token, + ) + + if (tree.truncated) { + throw new Error("The repository tree is too large to count in the browser") + } + + const files = tree.tree.filter( + (entry) => entry.type === "blob" && !isIgnored(entry.path, ignoredFiles), + ) + const locByLangs: { [lang: string]: number } = {} + + for (let index = 0; index < files.length; index += BLOB_BATCH_SIZE) { + const batch = files.slice(index, index + BLOB_BATCH_SIZE) + const lineCounts = await Promise.all( + batch.map(async (file) => { + const blob = await fetchGitHubJson( + `${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent( + repo, + )}/git/blobs/${file.sha}`, + token, + ) + + if (blob.encoding !== "base64") { + return { extension: getFileExtension(file.path), lines: 0 } + } + + return { + extension: getFileExtension(file.path), + lines: countTextLines(decodeBase64(blob.content)), + } + }), ) + + for (const { extension, lines } of lineCounts) { + locByLangs[extension] = (locByLangs[extension] || 0) + lines + } + } + + return { + loc: Object.values(locByLangs).reduce((total, lines) => total + lines, 0), + locByLangs, + lastFetched: 0, + } +} + +async function fetchPublicLoc(org: string, repo: string, branch: string, ignoredFiles: string[]) { + const encodedBranch = branch.split("/").map(encodeURIComponent).join("/") + const params = new URLSearchParams({ pretty: "false" }) + + if (ignoredFiles.length > 0) { + params.set("filter", ignoredFiles.map((ignored) => `!${ignored}$`).join(",")) } const branchPath = encodedBranch ? `/${encodedBranch}` : "" @@ -65,9 +172,7 @@ export async function fetchLoc( const apiBaseUrl = typeof configuredApiUrl === "string" && configuredApiUrl.length > 0 ? configuredApiUrl.replace(/\/$/, "") - : isPublic - ? "https://ghloc.ifels.dev" - : "https://ghloc-api.vercel.app" + : "https://ghloc.ifels.dev" const headers = new Headers() const authToken = import.meta.env.VITE_AUTH_TOKEN @@ -75,32 +180,53 @@ export async function fetchLoc( headers.set("Ghloc-Authorization", authToken) } - if ( - !isPublic && - typeof accessToken.accessToken === "string" && - accessToken.accessToken.length > 0 - ) { - headers.set("Authorization", `Bearer ${accessToken.accessToken}`) - params.set("salt", await sha1(accessToken.accessToken)) + const response = await fetch( + `${apiBaseUrl}/${encodeURIComponent(org)}/${encodeURIComponent(repo)}${branchPath}?${params}`, + { headers }, + ) + const data = await response.json() + + if (!response.ok || data.error) { + throw new Error(data.error || `LOC API request failed (${response.status})`) } - const url = `${apiBaseUrl}/${encodeURIComponent( - org, - )}/${encodeURIComponent(repo)}${branchPath}?${params}` + return data as LocData +} - let data: LocData = await fetch(url, { headers }) - .then((res) => res.json()) - .then((data) => { - if (typeof data !== "object") { - throw new Error("Invalid response: " + JSON.stringify(data)) - } +export function loadLoc(org: string, repo: string, branch: string): Promise { + return new Promise((resolve) => { + const key = makeKey(org, repo, branch) - if (data.error) { - throw new Error(data.error) - } + chrome.storage.local.get(key, (data: { [key: string]: unknown }) => { + const locData = data[key] as LocData - return data + if ( + typeof locData === "object" && + typeof locData.loc === "number" && + typeof locData.locByLangs === "object" && + typeof locData.lastFetched === "number" + ) { + resolve(locData as LocData) + } else { + resolve(null) + } }) + }) +} + +export async function fetchLoc( + org: string, + repo: string, + branch: string, + isPublic: boolean, +): Promise { + const ignoredFiles = await chrome.storage.sync.get("ignoredFiles") + const ignoredList = Array.isArray(ignoredFiles.ignoredFiles) + ? (ignoredFiles.ignoredFiles as string[]) + : [] + const data = isPublic + ? await fetchPublicLoc(org, repo, branch, ignoredList) + : await fetchLocalLoc(org, repo, branch, ignoredList) data.lastFetched = now() chrome.storage.local.set({ [makeKey(org, repo, branch)]: data }) From 36b4ee837460f4c75754637a083d3318fe4410b5 Mon Sep 17 00:00:00 2001 From: dongguacute Date: Wed, 19 Aug 2026 07:10:08 +0800 Subject: [PATCH 5/5] Refactor injection logic for GitHub LOC display - Removed the redundant `isInjected` function to simplify the injection process. - Updated the `injectStat` function to check for an existing element before creating a new one, improving efficiency. - Ensured proper placement of the injected element based on the presence of specific text in the last child of the root element. --- src/stat/injector.ts | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/stat/injector.ts b/src/stat/injector.ts index c8b93bb..ccbc8c3 100644 --- a/src/stat/injector.ts +++ b/src/stat/injector.ts @@ -2,10 +2,6 @@ import { JSX, render } from "preact" import { LocData } from "./loader" import { openFallbackPage } from "./util" -function isInjected(root: Element) { - return root.querySelector("#github-loc") !== null -} - function isPublicRepository() { const publicMeta = document.querySelector('meta[name="octolytics-dimension-repository_public"]') const publicValue = publicMeta?.getAttribute("content") @@ -42,11 +38,6 @@ export function locateRoot(): Promise<[Element, boolean]> { return false } - if (isInjected(root)) { - observer?.disconnect() - return true - } - observer?.disconnect() resolve([root, isPublicRepository()]) return true @@ -60,14 +51,18 @@ export function locateRoot(): Promise<[Element, boolean]> { } export function injectStat(root: Element, stat: JSX.Element) { - const div = document.createElement("div") - div.className = "mt-2" - div.id = "github-loc" - - if (root.lastElementChild?.firstElementChild?.textContent?.includes("Report")) { - root.insertBefore(div, root.lastElementChild) - } else { - root.appendChild(div) + const existing = root.querySelector("#github-loc") + const div = existing ?? document.createElement("div") + + if (!existing) { + div.className = "mt-2" + div.id = "github-loc" + + if (root.lastElementChild?.firstElementChild?.textContent?.includes("Report")) { + root.insertBefore(div, root.lastElementChild) + } else { + root.appendChild(div) + } } render(stat, div)