diff --git a/src/content.scss b/src/content.scss index 20156dc..11351f4 100644 --- a/src/content.scss +++ b/src/content.scss @@ -22,6 +22,7 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- } .#{$name}-ready { + // github .js-header-wrapper { background: $gray-dark; } @@ -29,6 +30,29 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- max-width: 1012px; margin: 0 auto; } + + // gitee + &.git-project { + #git-header-nav { + position: static; + } + + padding-top: 0; + } +} + +.with-gitako-spacing { + // gitee + &.git-project { + // width: auto; // shrink width + .ui.container { + // width: auto; + } + .site-content { + // min-width: auto; + // max-width: 1020px; + } + } } @media (min-width: $github-content-width) { @@ -517,6 +541,21 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- } } } + + // gitee + body.git-project { + // reset styles + input[type='text'], + input[type='password'], + .ui-autocomplete-input, + textarea, + .uneditable-input { + padding: initial; + // line-height: 18px; + border: none; + // border-radius: 3px; + } + } } @keyframes rotate { diff --git a/src/env.ts b/src/env.ts index 995588b..f5d192d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -5,4 +5,9 @@ export const GITHUB_OAUTH = { clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET, } +export const GITEE_OAUTH = { + clientId: process.env.GITEE_OAUTH_CLIENT_ID, + clientSecret: process.env.GITEE_OAUTH_CLIENT_SECRET, +} + export const VERSION = process.env.VERSION diff --git a/src/platforms/Gitee/API.ts b/src/platforms/Gitee/API.ts new file mode 100644 index 0000000..29aefcf --- /dev/null +++ b/src/platforms/Gitee/API.ts @@ -0,0 +1,117 @@ +import { raiseError } from 'analytics' +import { GITEE_OAUTH } from 'env' +import { errors } from 'platforms' + +function apiRateLimitExceeded(content: any /* safe any */) { + return content?.['documentation_url'] === 'https://developer.github.com/v3/#rate-limiting' +} + +function isEmptyProject(content: any /* safe any */) { + return content?.['message'] === 'Git Repository is empty.' +} + +function isBlockedProject(content: any /* safe any */) { + return content?.['message'] === 'Repository access blocked' +} + +async function request( + url: string, + { + accessToken, + }: { + accessToken?: string + } = {}, +) { + const headers = {} as HeadersInit & { + Authorization?: string + } + if (accessToken) { + headers.Authorization = `token ${accessToken}` + } + const res = await fetch(url, { headers }) + const contentType = res.headers.get('Content-Type') || res.headers.get('content-type') + if (!contentType) { + throw new Error(`Response has no content type`) + } else if (!contentType.includes('application/json')) { + throw new Error(`Response content type is ${contentType}`) + } + // About res.ok: + // True if res.status between 200~299 + // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Response/ok + if (res.ok) { + return res.json() + } else { + debugger + if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND) + else if (res.status === 500) throw new Error(errors.SERVER_FAULT) + else { + const content = await res.json() + if (apiRateLimitExceeded(content)) throw new Error(errors.API_RATE_LIMIT) + if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT) + if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT) + // Unknown type of error, report it! + raiseError(new Error(res.statusText)) + throw new Error(content && content.message) + } + } +} + +export async function getRepoMeta( + userName: string, + repoName: string, + accessToken?: string, +): Promise { + const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent( + repoName, + )}` + return await request(url, { accessToken }) +} + +export async function getTreeData( + userName: string, + repoName: string, + branchName: string, + accessToken?: string, +): Promise { + const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent( + repoName, + )}/git/trees/${encodeURIComponent(branchName)}?recursive=1` + return await request(url, { accessToken }) +} + +export async function getBlobData( + userName: string, + repoName: string, + sha: string, + accessToken?: string, +): Promise { + const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent( + repoName, + )}/git/blobs/${encodeURIComponent(sha)}` + return await request(url, { accessToken }) +} + +export async function OAuth(code: string): Promise { + if (!GITEE_OAUTH.clientId || !GITEE_OAUTH.clientSecret) + throw new Error(`No Gitee OAuth credientials`) + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: GITEE_OAUTH.clientId, + client_secret: GITEE_OAUTH.clientSecret, + }) + + const res = await fetch('https://gitee.com/oauth/token?' + params.toString(), { + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + method: 'post', + }) + return res.json() +} diff --git a/src/platforms/Gitee/DOMHelper.ts b/src/platforms/Gitee/DOMHelper.ts new file mode 100644 index 0000000..4b99612 --- /dev/null +++ b/src/platforms/Gitee/DOMHelper.ts @@ -0,0 +1,83 @@ +import { raiseError } from 'analytics' +import { Clippy, ClippyClassName } from 'components/Clippy' +import * as React from 'react' +import { $ } from 'utils/DOMHelper' +import { renderReact } from 'utils/general' + +export function isInRepoPage() { + const repoHeaderSelector = '.git-project-header-details' + return Boolean($(repoHeaderSelector)) +} + +export function isInCodePage() { + const branchListSelector = '#git-project-bread' + return Boolean($(branchListSelector)) +} + +export function getCurrentBranch() { + const selectedBranchButtonSelector = '#git-project-branch' + const branchButtonElement: HTMLElement = $(selectedBranchButtonSelector) + if (branchButtonElement) { + const branchNameSpanElement = branchButtonElement.querySelector('.text') + if (branchNameSpanElement) { + const partialBranchNameFromInnerText = branchNameSpanElement.textContent + if (!partialBranchNameFromInnerText?.includes('…')) return partialBranchNameFromInnerText + } + } + + raiseError(new Error('cannot get current branch')) +} + +const REPO_TYPE_PRIVATE = 'private' +const REPO_TYPE_PUBLIC = 'public' +export function getRepoPageType() { + const headerSelector = `.git-project-title .icon-lock` + return $( + headerSelector, + () => REPO_TYPE_PRIVATE, + () => REPO_TYPE_PUBLIC, + ) +} + +export function attachCopySnippet() { + const readmeSelector = '.file_content.markdown-body' + return $(readmeSelector, () => { + const readmeArticleSelector = '.file_content.markdown-body .highlight' + return $(readmeArticleSelector, readmeElement => { + const mouseOverCallback = async ({ target }: Event): Promise => { + if (target instanceof Element && target.nodeName === 'PRE') { + if ( + target.previousSibling === null || + !(target.previousSibling instanceof Element) || + !target.previousSibling.classList.contains(ClippyClassName) + ) { + /** + *
+ *
     
+             *    
+ *
   
+             *    
+ *
+ */ + if (target.parentNode) { + const clippyElement = await renderReact( + React.createElement(Clippy, { codeSnippetElement: target }), + ) + if (clippyElement instanceof HTMLElement) { + target.parentNode.insertBefore(clippyElement, target) + } + } + } + } + } + readmeElement.addEventListener('mouseover', mouseOverCallback) + return () => { + readmeElement.removeEventListener('mouseover', mouseOverCallback) + const buttons = document.querySelectorAll(`.${ClippyClassName}`) + buttons.forEach(button => { + button.parentElement?.removeChild(button) + }) + } + }) + }) +} diff --git a/src/platforms/Gitee/Request.d.ts b/src/platforms/Gitee/Request.d.ts new file mode 100644 index 0000000..14e0170 --- /dev/null +++ b/src/platforms/Gitee/Request.d.ts @@ -0,0 +1,40 @@ +declare namespace GiteeAPI { + type TreeItem = { + path: string + mode: string + sha: string + size: number + url: string + type: 'blob' | 'commit' | 'tree' + } + + type TreeData = { + sha: string + truncated: boolean + tree: TreeItem[] + url: string + } + + type MetaData = { + default_branch: string + html_url: string + parent: { + url: string + } + } + + type BlobData = { + encoding: 'base64' | string + sha: string + content?: string + size: number + url: string + } + + type OAuth = { + access_token?: string + scope?: string + token_type?: string + error_description?: string + } +} diff --git a/src/platforms/Gitee/URLHelper.ts b/src/platforms/Gitee/URLHelper.ts new file mode 100644 index 0000000..cbaa8d3 --- /dev/null +++ b/src/platforms/Gitee/URLHelper.ts @@ -0,0 +1,91 @@ +import { raiseError } from 'analytics' +import { isInRepoPage } from './DOMHelper' + +export function parse(): Partial & { path: string[] } { + const { pathname } = window.location + let [ + , + // ignore content before the first '/' + userName, + repoName, + type, + ...path // should be [...branchName.split('/'), ...filePath.split('/')] + ] = unescape(decodeURIComponent(pathname)).split('/') + return { + userName, + repoName, + branchName: undefined, + type, + path, + } +} + +export function parseSHA() { + const { type, path } = parse() + return type === 'blob' || type === 'tree' ? path[0] : undefined +} + +// route types related to determining if sidebar should show +const TYPES = { + TREE: 'tree', + BLOB: 'blob', + COMMIT: 'commit', + // known but not related types: issues, pulls, wiki, insight, + // TODO: record more types +} + +export function isInCodePage(metaData?: Partial) { + const mergedRepo = { ...parse(), ...metaData } + const { type, branchName } = mergedRepo + return Boolean( + isInRepoPage() && + (!type || type === TYPES.TREE || type === TYPES.BLOB) && + type !== TYPES.COMMIT && + (branchName || (!type && !branchName)), + ) +} + +function isCommitPath(path: string[]) { + return isCompleteCommitSHA(path[0]) +} + +function isCompleteCommitSHA(sha?: string) { + return typeof sha === 'string' && /^[abcdef0-9]{40}$/i.test(sha) +} + +export function getCurrentPath(branchName = '') { + const { path, type } = parse() + if (type === 'blob' || type === 'tree') { + if (isCommitPath(path)) { + // path = commit-SHA/path/to/item + path.shift() + } else { + // path = branch/name/path/to/item or HEAD/path/to/item + // HEAD is not a valid branch name. Getting HEAD means being detached. + if (path[0] === 'HEAD') path.shift() + else { + const splitBranchName = branchName.split('/') + while (splitBranchName.length) { + if ( + splitBranchName[0] === path[0] || + // Keep consuming as their heads are same + (splitBranchName.length === 1 && splitBranchName[0].startsWith(path[0])) + // This happens when visiting URLs like /blob/{commitSHA}/path/to/file + // and {commitSHA} is shorter than we got from DOM + ) { + splitBranchName.shift() + path.shift() + } else { + raiseError(new Error(`branch name and path prefix not match`), { + branchName, + path: parse().path, + }) + return [] + } + } + } + } + return path.map(decodeURIComponent) + } + return [] +} diff --git a/src/platforms/Gitee/components.tsx b/src/platforms/Gitee/components.tsx new file mode 100644 index 0000000..945f11d --- /dev/null +++ b/src/platforms/Gitee/components.tsx @@ -0,0 +1,39 @@ +import { GITEE_OAUTH } from 'env' +import * as React from 'react' + +export function GiteeAccessDeniedError({ hasToken }: { hasToken: boolean }) { + return ( +
+
Access Denied
+ {hasToken ? ( + <> +

+ Current access token is either invalid or not granted with permissions to access this + project. +

+

+ You can grant or request access{' '} + + here + {' '} + if you setup Gitako with OAuth. +

+ + ) : ( +

+ Gitako needs access token to read this project due to{' '} + + GitHub rate limiting + {' '} + and{' '} + + auth needs + + . Please setup access token in the settings panel below. +

+ )} +
+ ) +} diff --git a/src/platforms/Gitee/index.ts b/src/platforms/Gitee/index.ts new file mode 100644 index 0000000..1336572 --- /dev/null +++ b/src/platforms/Gitee/index.ts @@ -0,0 +1,167 @@ +import { platform } from 'platforms' +import * as React from 'react' +import { useEvent } from 'react-use' +import { resolveGitModules } from 'utils/gitSubmodule' +import { sortFoldersToFront } from 'utils/treeParser' +import * as API from './API' +import * as DOMHelper from './DOMHelper' +import * as URLHelper from './URLHelper' + +function parseTreeData(treeData: GiteeAPI.TreeData, metaData: MetaData) { + const { tree } = treeData + + // nodes are created from items and put onto tree + const pathToNode = new Map() + const pathToItem = new Map() + + const root: TreeNode = { name: '', path: '', contents: [], type: 'tree' } + pathToNode.set('', root) + + tree.forEach(item => pathToItem.set(item.path, item)) + tree.forEach(item => { + // bottom-up search for the deepest node created + let path = item.path + const itemsToCreateTreeNode: GiteeAPI.TreeItem[] = [] + while (path !== '' && !pathToNode.has(path)) { + const item = pathToItem.get(path) + if (item) { + itemsToCreateTreeNode.push(item) + } + // 'a/b' -> 'a' + // 'a' -> '' + path = path.substring(0, path.lastIndexOf('/')) + } + + // top-down create nodes + while (itemsToCreateTreeNode.length) { + const item = itemsToCreateTreeNode.pop() + if (!item) continue + const node: TreeNode = { + path: item.path || '', + type: item.type || 'blob', + name: item.path?.replace(/^.*\//, '') || '', + url: + item.url && item.type && item.path + ? getUrlForRedirect( + metaData.userName, + metaData.repoName, + metaData.branchName, + item.type, + item.path, + ) + : undefined, + contents: item.type === 'tree' ? [] : undefined, + } + const parentNode = pathToNode.get(path) + if (parentNode && parentNode.contents) { + parentNode.contents.push(node) + } + pathToNode.set(node.path, node) + path = node.path + } + }) + + sortFoldersToFront(root) + + return root +} + +function getUrlForRedirect( + userName: string, + repoName: string, + branchName: string, + type = 'blob', + path = '', +) { + return `https://gitee.com/${userName}/${repoName}/${type}/${branchName}/${path}` +} + +export const Gitee: Platform = { + resolveMeta() { + if (!DOMHelper.isInRepoPage()) { + return null + } + + let detectedBranchName + if (DOMHelper.isInCodePage()) { + // not working well with non-branch blob + // cannot handle '/' split branch name, should not use when possibly on branch page + detectedBranchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA() + } + + const metaData = { + ...URLHelper.parse(), + branchName: detectedBranchName || 'master', + } as MetaData + return metaData + }, + async getMetaData(rawMetaData, accessToken) { + const { userName, repoName, branchName } = rawMetaData + const data = await API.getRepoMeta(userName, repoName, accessToken) + const metaData: MetaData = { + userName, + repoName, + branchName, + userUrl: data?.parent?.url, + repoUrl: data?.html_url, + } + + return metaData + }, + async getTreeData(metaData, accessToken) { + const { userName, repoName, branchName } = metaData + const treeData = await API.getTreeData(userName, repoName, branchName) + const root = parseTreeData(treeData, metaData) + + const gitModules = root.contents?.find(item => item.name === '.gitmodules') + if (gitModules) { + if (metaData.userName && metaData.repoName && gitModules.sha) { + const blobData = await API.getBlobData( + metaData.repoName, + metaData.userName, + gitModules.sha, + accessToken, + ) + + if (blobData && blobData.encoding === 'base64' && blobData.content) { + resolveGitModules(root, Base64.decode(blobData.content)) + } + } + } + + return root + }, + shouldShow(metaData) { + return URLHelper.isInCodePage(metaData) + }, + getCurrentPath(branchName) { + return URLHelper.getCurrentPath(branchName) + }, + async setOAuth(code) { + const res = await API.OAuth(code) + const { access_token: accessToken, scope, error_description: errorDescription } = res + if (errorDescription) { + if (errorDescription === `The code passed is incorrect or expired.`) { + alert(`Gitako: The OAuth token has expired, please try again.`) + return null + } else { + throw new Error(errorDescription) + } + } else if (scope !== 'repo' || !accessToken) { + throw new Error(`Cannot resolve token response: '${JSON.stringify(res)}'`) + } + return accessToken + }, +} + +export function useGiteeAttachCopySnippetButton(copySnippetButton: boolean) { + const attachCopySnippetButton = React.useCallback( + function attachCopySnippetButton() { + if (platform !== Gitee) return + if (copySnippetButton) return DOMHelper.attachCopySnippet() || undefined // for the sake of react effect + }, + [copySnippetButton], + ) + React.useEffect(attachCopySnippetButton, [copySnippetButton]) + useEvent('pjax:complete', attachCopySnippetButton, window) +} diff --git a/src/platforms/index.ts b/src/platforms/index.ts index f12dadd..8f22bbd 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -1,5 +1,6 @@ import * as storageHelper from 'utils/storageHelper' import { dummyPlatformForTypeSafety } from './dummyPlatformForTypeSafety' +import { Gitee } from './Gitee' import { GitHub } from './GitHub' const platformsMap: Record< @@ -8,7 +9,7 @@ const platformsMap: Record< > = { GitHub: { platform: GitHub, hosts: ['github.com'] }, GitLab: { platform: GitHub, hosts: ['gitlab.com'] }, - Gitee: { platform: GitHub, hosts: ['gitee.com'] }, + Gitee: { platform: Gitee, hosts: ['gitee.com'] }, } const CustomDomainsStorageKey = 'CUSTOM_DOMAINS' diff --git a/src/utils/hooks/usePJAX.ts b/src/utils/hooks/usePJAX.ts index 090bc06..629f13a 100644 --- a/src/utils/hooks/usePJAX.ts +++ b/src/utils/hooks/usePJAX.ts @@ -14,6 +14,7 @@ export function usePJAX() { 'title', '[data-pjax="#js-repo-pjax-container"]', '.page-content', + '#git-project-content', ], scrollTo: false, analytics: false,