feat: setup gitee

This commit is contained in:
EnixCoda 2020-03-29 00:40:44 +08:00
parent 0f1f129c01
commit 07b520db07
No known key found for this signature in database
GPG key ID: 0C1A07377913A1DD
10 changed files with 584 additions and 1 deletions

View file

@ -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 {

View file

@ -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

117
src/platforms/Gitee/API.ts Normal file
View file

@ -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<GiteeAPI.MetaData> {
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<GiteeAPI.TreeData> {
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<GiteeAPI.BlobData> {
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<GiteeAPI.OAuth> {
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()
}

View file

@ -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<void> => {
if (target instanceof Element && target.nodeName === 'PRE') {
if (
target.previousSibling === null ||
!(target.previousSibling instanceof Element) ||
!target.previousSibling.classList.contains(ClippyClassName)
) {
/**
* <article>
* <pre></pre> <!-- case A -->
* <div class="highlight">
* <pre></pre> <!-- case B -->
* </div>
* </article>
*/
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)
})
}
})
})
}

40
src/platforms/Gitee/Request.d.ts vendored Normal file
View file

@ -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
}
}

View file

@ -0,0 +1,91 @@
import { raiseError } from 'analytics'
import { isInRepoPage } from './DOMHelper'
export function parse(): Partial<MetaData> & { 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<MetaData>) {
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 []
}

View file

@ -0,0 +1,39 @@
import { GITEE_OAUTH } from 'env'
import * as React from 'react'
export function GiteeAccessDeniedError({ hasToken }: { hasToken: boolean }) {
return (
<div className={'description'}>
<h5>Access Denied</h5>
{hasToken ? (
<>
<p>
Current access token is either invalid or not granted with permissions to access this
project.
</p>
<p>
You can grant or request access{' '}
<a
href={`https://github.com/settings/connections/applications/${GITEE_OAUTH.clientId}`}
>
here
</a>{' '}
if you setup Gitako with OAuth.
</p>
</>
) : (
<p>
Gitako needs access token to read this project due to{' '}
<a href="https://developer.github.com/v3/#rate-limiting" target="_blank">
GitHub rate limiting
</a>{' '}
and{' '}
<a href="https://developer.github.com/v3/#authentication" target="_blank">
auth needs
</a>
. Please setup access token in the settings panel below.
</p>
)}
</div>
)
}

View file

@ -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<string, TreeNode>()
const pathToItem = new Map<string, GiteeAPI.TreeItem>()
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)
}

View file

@ -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'

View file

@ -14,6 +14,7 @@ export function usePJAX() {
'title',
'[data-pjax="#js-repo-pjax-container"]',
'.page-content',
'#git-project-content',
],
scrollTo: false,
analytics: false,