From 85736300d8c808f5da3f41a5282763bed2f84a8c Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Wed, 25 Mar 2020 00:56:59 +0800 Subject: [PATCH 01/13] refactor: platforms --- package.json | 2 +- src/assets/icons/csv.d.ts | 4 + src/components/CopyFileButton.tsx | 3 +- src/components/FileExplorer.tsx | 13 +- src/components/MetaBar.tsx | 11 +- src/components/Node.tsx | 1 - src/components/SettingsBar.tsx | 14 +- src/components/SideBar.tsx | 123 +++------ src/components/SimpleToggleField.tsx | 14 +- .../settings/AccessTokenSettings.tsx | 4 +- src/components/settings/FileTreeSettings.tsx | 2 +- src/containers/ConfigsContext.tsx | 5 + src/content.scss | 4 +- src/content.tsx | 45 ++-- src/driver/core/FileExplorer.ts | 133 ++-------- src/driver/core/SideBar.ts | 85 +++---- src/env.ts | 7 +- src/global.d.ts | 27 +- src/manifest.json | 4 +- src/platforms/GitHub/API.ts | 94 +++++++ src/platforms/GitHub/DOMHelper.ts | 191 ++++++++++++++ src/platforms/GitHub/Request.d.ts | 40 +++ src/{utils => platforms/GitHub}/URLHelper.ts | 6 +- src/platforms/GitHub/components.tsx | 39 +++ src/platforms/GitHub/index.ts | 179 ++++++++++++++ src/platforms/dummyPlatformForTypeSafety.ts | 16 ++ src/platforms/index.ts | 27 ++ src/platforms/platform.d.ts | 8 + src/utils/DOMHelper.ts | 233 +----------------- src/utils/GitHubHelper.ts | 141 ----------- src/utils/VisibleNodesGenerator.ts | 10 - src/utils/general.ts | 1 - src/utils/gitSubmodule.ts | 78 ++++++ src/utils/hooks/usePJAX.ts | 39 +++ src/utils/hooks/useProgressBar.ts | 22 ++ src/utils/parseIconMapCSV.ts | 1 - src/utils/treeParser.ts | 87 +------ 37 files changed, 934 insertions(+), 779 deletions(-) create mode 100644 src/assets/icons/csv.d.ts create mode 100644 src/platforms/GitHub/API.ts create mode 100644 src/platforms/GitHub/DOMHelper.ts create mode 100644 src/platforms/GitHub/Request.d.ts rename src/{utils => platforms/GitHub}/URLHelper.ts (94%) create mode 100644 src/platforms/GitHub/components.tsx create mode 100644 src/platforms/GitHub/index.ts create mode 100644 src/platforms/dummyPlatformForTypeSafety.ts create mode 100644 src/platforms/index.ts create mode 100644 src/platforms/platform.d.ts delete mode 100644 src/utils/GitHubHelper.ts create mode 100644 src/utils/gitSubmodule.ts create mode 100644 src/utils/hooks/usePJAX.ts create mode 100644 src/utils/hooks/useProgressBar.ts diff --git a/package.json b/package.json index ee2ef90..18398ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gitako", "version": "1.0.1", - "description": "Awesome GitHub file tree.", + "description": "File tree for GitHub and other platforms.", "repository": "https://github.com/EnixCoda/Gitako", "author": "EnixCoda", "license": "MIT", diff --git a/src/assets/icons/csv.d.ts b/src/assets/icons/csv.d.ts new file mode 100644 index 0000000..7a5ed7f --- /dev/null +++ b/src/assets/icons/csv.d.ts @@ -0,0 +1,4 @@ +declare module '*.csv' { + const content: string + export default content +} diff --git a/src/components/CopyFileButton.tsx b/src/components/CopyFileButton.tsx index dba3066..26040a8 100644 --- a/src/components/CopyFileButton.tsx +++ b/src/components/CopyFileButton.tsx @@ -1,6 +1,7 @@ +import { getCodeElement } from 'platforms/GitHub/DOMHelper' import * as React from 'react' import { cx } from 'utils/cx' -import { copyElementContent, getCodeElement } from 'utils/DOMHelper' +import { copyElementContent } from 'utils/DOMHelper' type Props = {} diff --git a/src/components/FileExplorer.tsx b/src/components/FileExplorer.tsx index b5f0509..a102f80 100644 --- a/src/components/FileExplorer.tsx +++ b/src/components/FileExplorer.tsx @@ -6,13 +6,13 @@ import { useConfigs } from 'containers/ConfigsContext' import { connect } from 'driver/connect' import { FileExplorerCore } from 'driver/core' import { ConnectorState, Props } from 'driver/core/FileExplorer' +import { platform } from 'platforms' import * as React from 'react' import { useEvent, usePrevious } from 'react-use' import { FixedSizeList as List, ListChildComponentProps, ListProps } from 'react-window' import { cx } from 'utils/cx' import { useOnLocationChange } from 'utils/hooks/useOnLocationChange' -import { getCurrentPath } from 'utils/URLHelper' -import { TreeNode, VisibleNodes } from 'utils/VisibleNodesGenerator' +import { VisibleNodes } from 'utils/VisibleNodesGenerator' import { Icon } from './Icon' import { SizeObserver } from './SizeObserver' @@ -30,9 +30,9 @@ const RawFileExplorer: React.FC = function RawFileExplor }, []) React.useEffect(() => { - const { setUpTree, treeData, metaData } = props - setUpTree({ treeData, metaData, compressSingletonFolder, accessToken }) - }, [props.setUpTree, props.treeData, compressSingletonFolder, accessToken]) + const { setUpTree, treeRoot, metaData } = props + setUpTree({ treeRoot, metaData, compressSingletonFolder, accessToken }) + }, [props.setUpTree, props.treeRoot, compressSingletonFolder, accessToken]) React.useEffect(() => { const { execAfterRender } = props @@ -201,7 +201,8 @@ function ListView({ }, [listRef.current, focusedNode, nodes.length]) const goToCurrentItem = React.useCallback(() => { - expandTo(getCurrentPath(metaData.branchName)) + const targetPath = platform.getCurrentPath(metaData.branchName) + if (targetPath) expandTo(targetPath) }, [metaData.branchName]) useOnLocationChange(goToCurrentItem) useEvent('pjax:complete', goToCurrentItem, window) diff --git a/src/components/MetaBar.tsx b/src/components/MetaBar.tsx index 3593aee..d3eabcc 100644 --- a/src/components/MetaBar.tsx +++ b/src/components/MetaBar.tsx @@ -1,22 +1,19 @@ import { Breadcrumb } from '@primer/components' import * as React from 'react' -import { MetaData } from 'utils/GitHubHelper' type Props = { metaData: MetaData } -export function MetaBar({ metaData }: Props) { - const userUrl = metaData?.api?.owner.html_url - const repoUrl = metaData?.api?.html_url +export function MetaBar({ metaData: { userName, repoName, branchName, repoUrl, userUrl } }: Props) { return (
- {metaData.userName} + {userName} - {metaData.repoName} + {repoName} - {metaData.branchName} + {branchName}
) diff --git a/src/components/Node.tsx b/src/components/Node.tsx index 2867083..a67cdf1 100644 --- a/src/components/Node.tsx +++ b/src/components/Node.tsx @@ -3,7 +3,6 @@ import { useConfigs } from 'containers/ConfigsContext' import * as React from 'react' import { cx } from 'utils/cx' import { OperatingSystems, os } from 'utils/general' -import { TreeNode } from 'utils/VisibleNodesGenerator' import { getFileIconSrc, getFolderIconSrc } from '../utils/parseIconMapCSV' import { Icon } from './Icon' diff --git a/src/components/SettingsBar.tsx b/src/components/SettingsBar.tsx index b1554a8..3a966b0 100644 --- a/src/components/SettingsBar.tsx +++ b/src/components/SettingsBar.tsx @@ -2,13 +2,12 @@ import { Link } from '@primer/components' import { Icon } from 'components/Icon' import { VERSION } from 'env' import * as React from 'react' -import { Config } from 'utils/configHelper' import { useStates } from 'utils/hooks/useStates' import { AccessTokenSettings } from './settings/AccessTokenSettings' import { FileTreeSettings } from './settings/FileTreeSettings' import { SettingsSection } from './settings/SettingsSection' import { SidebarSettings } from './settings/SidebarSettings' -import { SimpleToggleField } from './SimpleToggleField' +import { SimpleField, SimpleToggleField } from './SimpleToggleField' const WIKI_HOME_LINK = 'https://github.com/EnixCoda/Gitako/wiki' export const wikiLinks = { @@ -24,17 +23,6 @@ type Props = { toggleShowSettings: () => void } -export type SimpleField = { - key: keyof Config - label: string - wikiLink?: string - description?: string - overwrite?: { - value: (value: T) => boolean - onChange: (checked: boolean) => any - } -} - const moreFields: SimpleField[] = [ { key: 'copyFileButton', diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 680895a..ba84776 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -9,25 +9,33 @@ import { useConfigs } from 'containers/ConfigsContext' import { connect } from 'driver/connect' import { SideBarCore } from 'driver/core' import { ConnectorState, Props } from 'driver/core/SideBar' -import { oauth } from 'env' +import { platform } from 'platforms' +import { useGitHubAttachCopyFileButton, useGitHubAttachCopySnippetButton } from 'platforms/GitHub' +import { GitHubAccessDeniedError } from 'platforms/GitHub/components' import * as React from 'react' import { useEvent, useUpdateEffect } from 'react-use' import { cx } from 'utils/cx' -import * as DOMHelper from 'utils/DOMHelper' -import { JSONRequest, parseURLSearch } from 'utils/general' +import { parseURLSearch } from 'utils/general' +import { usePJAX } from 'utils/hooks/usePJAX' import * as keyHelper from 'utils/keyHelper' -import * as URLHelper from 'utils/URLHelper' const RawGitako: React.FC = function RawGitako(props) { const configContext = useConfigs() const accessToken = props.configContext.val.access_token + const intelligentToggle = configContext.val.intelligentToggle + React.useEffect(() => { + const shouldShow = + intelligentToggle === null ? platform.shouldShow(props.metaData) : intelligentToggle + props.setShouldShow(shouldShow) + }, [intelligentToggle]) + React.useEffect(() => { const { init } = props ;(async function() { if (!accessToken) { const accessToken = await trySetUpAccessTokenWithCode() - configContext.set({ access_token: accessToken }) + configContext.set({ access_token: accessToken || undefined }) } init() })() @@ -53,7 +61,7 @@ const RawGitako: React.FC = function RawGitako(props) { function updateSideBarVisibility() { if (configContext.val.intelligentToggle === null) { props.setShouldShow( - URLHelper.isInCodePage({ + platform.shouldShow({ branchName: props.metaData?.branchName, }), ) @@ -63,33 +71,23 @@ const RawGitako: React.FC = function RawGitako(props) { ) useEvent('pjax:complete', updateSideBarVisibility, window) - const attachCopyFileButton = React.useCallback( - function attachCopyFileButton() { - if (configContext.val.copyFileButton) return DOMHelper.attachCopyFileBtn() || undefined // for the sake of react effect - }, - [configContext.val.copyFileButton], - ) - React.useEffect(attachCopyFileButton, [configContext.val.copyFileButton]) - useEvent('pjax:complete', attachCopyFileButton, window) + const copyFileButton = configContext.val.copyFileButton + useGitHubAttachCopyFileButton(copyFileButton) - const attachCopySnippetButton = React.useCallback( - function attachCopySnippetButton() { - if (configContext.val.copySnippetButton) return DOMHelper.attachCopySnippet() || undefined // for the sake of react effect - }, - [configContext.val.copySnippetButton], - ) - React.useEffect(attachCopySnippetButton, [configContext.val.copySnippetButton]) - useEvent('pjax:complete', attachCopySnippetButton, window) + const copySnippetButton = configContext.val.copySnippetButton + useGitHubAttachCopySnippetButton(copySnippetButton) // init again when setting new accessToken useUpdateEffect(() => { props.init() - }, [accessToken || '']) // fallback for preventing duplicated requests + }, [accessToken || '']) // '' prevents duplicated requests + + const loadWithPJAX = usePJAX() const { errorDueToAuth, metaData, - treeData, + treeData: treeRoot, baseSize, error, shouldShow, @@ -111,17 +109,20 @@ const RawGitako: React.FC = function RawGitako(props) {
{metaData && } - {errorDueToAuth - ? renderAccessDeniedError(Boolean(accessToken)) - : metaData && ( - - )} + {errorDueToAuth ? ( + + ) : ( + metaData && ( + + ) + )}
@@ -140,61 +141,15 @@ RawGitako.defaultProps = { export const SideBar = connect(SideBarCore)(RawGitako) -function renderAccessDeniedError(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. -

- )} -
- ) +function AccessDeniedError({ hasToken }: { hasToken: boolean }) { + return } async function trySetUpAccessTokenWithCode() { try { const search = parseURLSearch() if ('code' in search) { - const res = await JSONRequest('https://github.com/login/oauth/access_token', { - code: search.code, - client_id: oauth.clientId, - client_secret: oauth.clientSecret, - }) - const { access_token: accessToken, scope, error_description: errorDescription } = res - if (errorDescription) { - const TOKEN_EXPIRED_DESCRIPTION = `The code passed is incorrect or expired.` - if (errorDescription === TOKEN_EXPIRED_DESCRIPTION) { - alert(`Gitako: The OAuth token has expired, please try again.`) - } else { - throw new Error(errorDescription) - } - } else if (scope !== 'repo' || !accessToken) { - throw new Error(`Cannot resolve token response: '${JSON.stringify(res)}'`) - } + const accessToken = await platform.setOAuth(search.code) window.history.pushState( {}, 'removed search param', diff --git a/src/components/SimpleToggleField.tsx b/src/components/SimpleToggleField.tsx index e45ea2a..68cd8f8 100644 --- a/src/components/SimpleToggleField.tsx +++ b/src/components/SimpleToggleField.tsx @@ -1,10 +1,22 @@ import { useConfigs } from 'containers/ConfigsContext' import * as React from 'react' +import { Config } from 'utils/configHelper' import { Field } from './settings/Field' -import { SimpleField } from './SettingsBar' + +export type SimpleField = { + key: keyof Config + label: string + wikiLink?: string + description?: string + overwrite?: { + value: (value: T) => boolean + onChange: (checked: boolean) => any + } +} type Props = { field: SimpleField + onChange?(): void } diff --git a/src/components/settings/AccessTokenSettings.tsx b/src/components/settings/AccessTokenSettings.tsx index c4a3e71..9846326 100644 --- a/src/components/settings/AccessTokenSettings.tsx +++ b/src/components/settings/AccessTokenSettings.tsx @@ -1,7 +1,7 @@ import { Button, TextInput } from '@primer/components' import { wikiLinks } from 'components/SettingsBar' import { useConfigs } from 'containers/ConfigsContext' -import { oauth } from 'env' +import { GITHUB_OAUTH } from 'env' import * as React from 'react' import { useStates } from 'utils/hooks/useStates' import { SettingsSection } from './SettingsSection' @@ -79,7 +79,7 @@ export function AccessTokenSettings(props: React.PropsWithChildren) { onClick={() => { // use js here to make sure redirect_uri is latest url const url = `https://github.com/login/oauth/authorize?client_id=${ - oauth.clientId + GITHUB_OAUTH.clientId }&scope=repo&redirect_uri=${encodeURIComponent(window.location.href)}` window.location.href = url }} diff --git a/src/components/settings/FileTreeSettings.tsx b/src/components/settings/FileTreeSettings.tsx index 67365c2..f47ea61 100644 --- a/src/components/settings/FileTreeSettings.tsx +++ b/src/components/settings/FileTreeSettings.tsx @@ -24,7 +24,7 @@ const options: { { key: 'native', value: 'native', - label: `Native GitHub icons`, + label: `GitHub icons`, }, ] diff --git a/src/containers/ConfigsContext.tsx b/src/containers/ConfigsContext.tsx index 714ce3a..2dd168a 100644 --- a/src/containers/ConfigsContext.tsx +++ b/src/containers/ConfigsContext.tsx @@ -4,6 +4,11 @@ import { Config } from 'utils/configHelper' type Props = {} +type PartialValSet = { + val: T + set: (val: Partial) => void +} + type ContextShape = PartialValSet export type ConfigsContextShape = ContextShape diff --git a/src/content.scss b/src/content.scss index d6fda16..20156dc 100644 --- a/src/content.scss +++ b/src/content.scss @@ -1,7 +1,5 @@ @import '~nprogress/nprogress.css'; -@import '~@primer/css/base/index.scss'; - @import '~@primer/css/support/variables/colors.scss'; @import '~@primer/css/support/variables/color-system.scss'; @import '~@primer/css/support/variables/typography.scss'; @@ -177,6 +175,8 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- } .#{$name}-side-bar { + @import '~@primer/css/base/index.scss'; + .#{$name}-position-wrapper { position: fixed; top: 0; diff --git a/src/content.tsx b/src/content.tsx index cab432d..f4ed9b5 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -1,30 +1,33 @@ import { withErrorLog } from 'analytics' import { Gitako } from 'components/Gitako' import { addMiddleware } from 'driver/connect' +import { platform } from 'platforms' import * as React from 'react' import * as ReactDOM from 'react-dom' import './content.scss' -addMiddleware(withErrorLog) +if (platform.resolveMeta()) { + addMiddleware(withErrorLog) -function init() { - const SideBarElement = document.createElement('div') - document.body.appendChild(SideBarElement) - ReactDOM.render(, SideBarElement) + function init() { + // injects a copy of stylesheets so that other extensions(e.g. dark reader) could read + function injectStyles(url: string) { + var linkElement = document.createElement('link') + linkElement.rel = 'stylesheet' + linkElement.setAttribute('href', url) + document.head.appendChild(linkElement) + } + + injectStyles(browser.extension.getURL('content.css')) + + const SideBarElement = document.createElement('div') + document.body.appendChild(SideBarElement) + ReactDOM.render(, SideBarElement) + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init) + } else { + init() + } } - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init) -} else { - init() -} - -// injects a copy of stylesheets so that other extensions(e.g. dark reader) could read -function injectStyles(url: string) { - var linkElement = document.createElement('link') - linkElement.rel = 'stylesheet' - linkElement.setAttribute('href', url) - document.head.appendChild(linkElement) -} - -injectStyles(browser.extension.getURL('content.css')) diff --git a/src/driver/core/FileExplorer.ts b/src/driver/core/FileExplorer.ts index 5928b76..8eeb3a2 100644 --- a/src/driver/core/FileExplorer.ts +++ b/src/driver/core/FileExplorer.ts @@ -1,21 +1,17 @@ import { GetCreatedMethod, MethodCreator } from 'driver/connect' -import * as ini from 'ini' -import { Base64 } from 'js-base64' +import { platform } from 'platforms' import { Config } from 'utils/configHelper' import * as DOMHelper from 'utils/DOMHelper' -import { findNode, searchKeyToRegexps } from 'utils/general' -import * as GitHubHelper from 'utils/GitHubHelper' -import { BlobData } from 'utils/GitHubHelper' -import * as treeParser from 'utils/treeParser' -import * as URLHelper from 'utils/URLHelper' -import { TreeNode, VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' +import { searchKeyToRegexps } from 'utils/general' +import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' export type Props = { - treeData?: GitHubHelper.TreeData - metaData: GitHubHelper.MetaData + treeRoot?: TreeNode + metaData: MetaData freeze: boolean accessToken: string | undefined toggleShowSettings: React.MouseEventHandler + loadWithPJAX(url: string): void } export type ConnectorState = { @@ -35,9 +31,11 @@ export type ConnectorState = { expandTo: GetCreatedMethod } -type DepthMap = Map - -function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode, depths: DepthMap) { +function getVisibleParentNode( + nodes: TreeNode[], + focusedNode: TreeNode, + depths: Map, +) { const focusedNodeIndex = nodes.indexOf(focusedNode) const focusedNodeDepth = depths.get(focusedNode) let indexOfParentNode = focusedNodeIndex - 1 @@ -62,102 +60,13 @@ type BoundMethodCreator = MethodCreator () => dispatch.call(setStateText, 'Fetching File List...') -const githubSubModuleURLRegex = { - HTTP: /^https?:\/\/.*?$/, - HTTPGit: /^https:\/\/github.com\/.*?\/.*?\.git$/, - git: /^git@github.com:(.*?)\/(.*?)\.git$/, -} - -function transformModuleGitURL(node: TreeNode, URL: string) { - const matched = URL.match(githubSubModuleURLRegex.git) - if (!matched) return - const [_, userName, repoName] = matched - return appendCommitPath(`https://github.com/${userName}/${repoName}`, node) -} - -function cutDotGit(URL: string) { - return URL.replace(/\.git$/, '') -} - -function appendCommitPath(URL: string, node: TreeNode) { - return URL.replace(/\/?$/, `/tree/${node.sha}`) -} - -function transformModuleHTTPDotGitURL(node: TreeNode, URL: string) { - return appendCommitPath(cutDotGit(URL), node) -} - -function transformModuleHTTPURL(node: TreeNode, URL: string) { - return appendCommitPath(URL, node) -} - -type Parsed = { - [key: string]: ParsedModule | Parsed -} - -type ParsedModule = { - path?: string - url?: string -} - -function resolveGitModules(root: TreeNode, blobData: BlobData) { - if (blobData) { - if (blobData.encoding === 'base64' && blobData.content && Array.isArray(root.contents)) { - const content = Base64.decode(blobData.content) - const parsed: Parsed = ini.parse(content) - handleParsed(root, parsed) - } - } -} - -function handleParsed(root: TreeNode, parsed: Parsed) { - Object.values(parsed).forEach(value => { - if (typeof value === 'string') return - const { url, path } = value - if (typeof url === 'string' && typeof path === 'string') { - const node = findNode(root, path.split('/')) - if (node) { - if (githubSubModuleURLRegex.HTTPGit.test(url)) { - node.url = transformModuleHTTPDotGitURL(node, url) - } else if (githubSubModuleURLRegex.git.test(url)) { - node.url = transformModuleGitURL(node, url) - } else if (githubSubModuleURLRegex.HTTP.test(url)) { - node.url = transformModuleHTTPURL(node, url) - } else { - node.accessDenied = true - } - } else { - // It turns out that we did not miss any submodule after a lot of tests. - // Turning this off. - // raiseError(new Error(`Submodule node not found`), { path }) - } - } else { - handleParsed(root, value as Parsed) - } - }) -} - export const setUpTree: BoundMethodCreator<[ - Pick & Pick, -]> = dispatch => async ({ treeData, metaData, compressSingletonFolder, accessToken }) => { - if (!treeData) return + Pick & Pick, +]> = dispatch => async ({ treeRoot, metaData, compressSingletonFolder, accessToken }) => { + if (!treeRoot) return dispatch.call(setStateText, 'Rendering File List...') - const { root, gitModules } = treeParser.parse(treeData, metaData) - if (gitModules) { - if (metaData.userName && metaData.repoName && gitModules.sha) { - const blobData = await GitHubHelper.getBlobData({ - userName: metaData.userName, - repoName: metaData.repoName, - sha: gitModules.sha, - accessToken, - }) - - resolveGitModules(root as TreeNode, blobData) - } - } - - visibleNodesGenerator = new VisibleNodesGenerator(root as TreeNode, { + visibleNodesGenerator = new VisibleNodesGenerator(treeRoot as TreeNode, { compress: compressSingletonFolder, }) @@ -165,7 +74,8 @@ export const setUpTree: BoundMethodCreator<[ tasksAfterRender.push(DOMHelper.focusSearchInput) dispatch.call(setStateText, '') - dispatch.call(goTo, URLHelper.getCurrentPath(metaData.branchName)) + const targetPath = platform.getCurrentPath(metaData.branchName) + if (targetPath) dispatch.call(goTo, targetPath) } export const execAfterRender: BoundMethodCreator = dispatch => () => { @@ -183,7 +93,7 @@ export const setStateText: BoundMethodCreator<[ConnectorState['stateText']]> = d }) export const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch => event => { - const [{ searched, visibleNodes }] = dispatch.get() + const [{ searched, visibleNodes }, { loadWithPJAX }] = dispatch.get() if (!visibleNodes) return const { nodes, focusedNode, expandedNodes, depths } = visibleNodes function handleVerticalMove(index: number) { @@ -239,7 +149,7 @@ export const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch dispatch.call(setExpand, focusedNode, true) } } else if (focusedNode.type === 'blob') { - if (focusedNode.url) DOMHelper.loadWithPJAX(focusedNode.url) + if (focusedNode.url) loadWithPJAX(focusedNode.url) } else if (focusedNode.type === 'commit') { window.open(focusedNode.url) } @@ -254,7 +164,7 @@ export const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch } } else if (focusedNode.type === 'blob') { if (searched) dispatch.call(goTo, focusedNode.path.split('/')) - else if (focusedNode.url) DOMHelper.loadWithPJAX(focusedNode.url) + else if (focusedNode.url) loadWithPJAX(focusedNode.url) } else if (focusedNode.type === 'commit') { window.open(focusedNode.url) } @@ -335,8 +245,9 @@ export const onNodeClick: BoundMethodCreator<[TreeNode]> = dispatch => node => { if (node.type === 'tree') { dispatch.call(toggleNodeExpansion, node, true) } else if (node.type === 'blob') { + const [, { loadWithPJAX }] = dispatch.get() dispatch.call(focusNode, node, true) - if (node.url) DOMHelper.loadWithPJAX(node.url) + if (node.url) loadWithPJAX(node.url) } else if (node.type === 'commit') { if (node.url) { window.open(node.url, '_blank') diff --git a/src/driver/core/SideBar.ts b/src/driver/core/SideBar.ts index c3ed21a..4064d77 100644 --- a/src/driver/core/SideBar.ts +++ b/src/driver/core/SideBar.ts @@ -1,9 +1,7 @@ import { ConfigsContextShape } from 'containers/ConfigsContext' import { GetCreatedMethod, MethodCreator } from 'driver/connect' +import { errors, platform } from 'platforms' import * as DOMHelper from 'utils/DOMHelper' -import * as GitHubHelper from 'utils/GitHubHelper' -import { MetaData, TreeData } from 'utils/GitHubHelper' -import * as URLHelper from 'utils/URLHelper' export type Props = { configContext: ConfigsContextShape @@ -21,7 +19,7 @@ export type ConnectorState = { // meta data for the repository metaData?: MetaData // file tree data - treeData?: TreeData + treeData?: TreeNode logoContainerElement: Element | null disabled: boolean initializingPromise: Promise | null @@ -49,37 +47,36 @@ export const init: BoundMethodCreator = dispatch => async () => { }) try { - if (!URLHelper.isInRepoPage()) { + const metaData = platform.resolveMeta() + if (!metaData) { dispatch.set({ disabled: true }) return } + const detectedBranchName = metaData.branchName DOMHelper.markGitakoReadyState(true) dispatch.set({ errorDueToAuth: false, showSettings: false, logoContainerElement: DOMHelper.insertLogoMountPoint(), }) - let detectedBranchName - const metaData = URLHelper.parse() - if (DOMHelper.isInCodePage()) { - detectedBranchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA() // not working well with non-branch blob // cannot handle '/' split branch name, should not use when possibly on branch page - } - metaData.branchName = detectedBranchName || 'master' dispatch.call(setMetaData, metaData) + const [, { configContext }] = dispatch.get() const { sideBarWidth, access_token: accessToken, intelligentToggle } = configContext.val dispatch.set({ baseSize: sideBarWidth, }) - if (!metaData.branchName || !metaData.userName) return - const getTreeDataAggressively = GitHubHelper.getTreeData({ - branchName: metaData.branchName, - userName: metaData.userName, - repoName: metaData.repoName, + if (!metaData.branchName || !metaData.userName || !metaData.repoName) return + const getTreeDataAggressively = platform.getTreeData( + { + branchName: metaData.branchName, + userName: metaData.userName, + repoName: metaData.repoName, + }, accessToken, - }) - const caughtAggressiveError = getTreeDataAggressively.catch(error => { + ) + const caughtAggressiveError = getTreeDataAggressively?.catch(error => { // 1. the repo has no master branch // 2. detect branch name from DOM failed // 3. not very possible... @@ -87,19 +84,32 @@ export const init: BoundMethodCreator = dispatch => async () => { return error }) let getTreeData = getTreeDataAggressively - const metaDataFromAPI = await GitHubHelper.getRepoMeta({ ...metaData, accessToken }) - const projectDefaultBranchName = metaDataFromAPI['default_branch'] - if (!detectedBranchName && projectDefaultBranchName !== metaData.branchName) { + const metaDataFromAPI = await platform.getMetaData( + { + branchName: metaData.branchName, + userName: metaData.userName, + repoName: metaData.repoName, + }, + accessToken, + ) + const projectDefaultBranchName = metaDataFromAPI?.defaultBranchName + if ( + !detectedBranchName && + projectDefaultBranchName && + projectDefaultBranchName !== metaData.branchName + ) { // Accessing repository's non-homepage(no branch name in URL, nor in DOM) // We predicted its default branch to be 'master' and sent aggressive request // Throw that request due to the repo do not use {defaultBranchName} as default branch metaData.branchName = projectDefaultBranchName - getTreeData = GitHubHelper.getTreeData({ - branchName: metaData.branchName, - userName: metaData.userName, - repoName: metaData.repoName, + getTreeData = platform.getTreeData( + { + branchName: metaData.branchName, + userName: metaData.userName, + repoName: metaData.repoName, + }, accessToken, - }) + ) } else { caughtAggressiveError.then(error => { // aggressive requested correct branch but ends in failure (e.g. project is empty) @@ -109,18 +119,14 @@ export const init: BoundMethodCreator = dispatch => async () => { }) } getTreeData - .then(treeData => { + .then(async treeData => { if (treeData) { - // in an unknown rare case this NOT happen dispatch.set({ treeData }) } }) .catch(err => dispatch.call(handleError, err)) - Object.assign(metaData, { api: metaDataFromAPI }) + Object.assign(metaData, metaDataFromAPI) dispatch.call(setMetaData, metaData) - const shouldShow = - intelligentToggle === null ? URLHelper.isInCodePage(metaData) : intelligentToggle - dispatch.call(setShouldShow, shouldShow) } catch (err) { dispatch.call(handleError, err) } finally { @@ -129,17 +135,16 @@ export const init: BoundMethodCreator = dispatch => async () => { } export const handleError: BoundMethodCreator<[Error]> = dispatch => async err => { - if (err.message === GitHubHelper.EMPTY_PROJECT) { + if (err.message === errors.EMPTY_PROJECT) { dispatch.call(setError, 'This project seems to be empty.') - } else if (err.message === GitHubHelper.BLOCKED_PROJECT) { + } else if (err.message === errors.BLOCKED_PROJECT) { dispatch.call(setError, 'This project is blocked.') } else if ( - err.message === GitHubHelper.NOT_FOUND || - err.message === GitHubHelper.BAD_CREDENTIALS || - err.message === GitHubHelper.API_RATE_LIMIT + err.message === errors.NOT_FOUND || + err.message === errors.BAD_CREDENTIALS || + err.message === errors.API_RATE_LIMIT ) { dispatch.set({ errorDueToAuth: true }) - dispatch.call(setShowSettings, true) } else { DOMHelper.markGitakoReadyState(false) dispatch.call(setError, 'Some thing went wrong.') @@ -176,9 +181,5 @@ export const toggleShowSettings: BoundMethodCreator = dispatch => () => showSettings: !showSettings, })) -export const setShowSettings: BoundMethodCreator<[ - ConnectorState['showSettings'], -]> = dispatch => showSettings => dispatch.set({ showSettings }) - export const setMetaData: BoundMethodCreator<[ConnectorState['metaData']]> = dispatch => metaData => dispatch.set({ metaData }) diff --git a/src/env.ts b/src/env.ts index bba237d..995588b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,11 +1,6 @@ export const IN_PRODUCTION_MODE = process.env.NODE_ENV === 'production' -type KnownPlatform = 'chrome' | 'firefox' -type Platform = KnownPlatform | Exclude - -export const PLATFORM: Platform = process.env.PLATFORM || 'unknown' - -export const oauth = { +export const GITHUB_OAUTH = { clientId: process.env.GITHUB_OAUTH_CLIENT_ID, clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET, } diff --git a/src/global.d.ts b/src/global.d.ts index 4c6539f..b8b847f 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,14 +1,19 @@ -type ValSet = { - val: T - set: (val: T) => void +type MetaData = { + userName: string + repoName: string + branchName: string + defaultBranchName?: string + repoUrl?: string + userUrl?: string + type?: 'tree' | 'blob' | string } -type PartialValSet = { - val: T - set: (val: Partial) => void -} - -declare module '*.csv' { - const content: string - export default content +type TreeNode = { + name: string + contents?: TreeNode[] + path: string + type: 'tree' | 'blob' | 'commit' + url?: string + sha?: string + accessDenied?: boolean } diff --git a/src/manifest.json b/src/manifest.json index aefade9..5df84c4 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -6,11 +6,11 @@ "128": "icons/Gitako-128.png", "256": "icons/Gitako-256.png" }, - "permissions": ["storage", "*://*.github.com/*", "*://*.sentry.io/*"], + "permissions": ["storage", ""], "web_accessible_resources": ["icons/vscode/*", "content.css"], "content_scripts": [ { - "matches": ["https://github.com/*"], + "matches": [""], "js": ["firefox-shim.js", "browser-polyfill.js", "content.js"] } ] diff --git a/src/platforms/GitHub/API.ts b/src/platforms/GitHub/API.ts new file mode 100644 index 0000000..752466e --- /dev/null +++ b/src/platforms/GitHub/API.ts @@ -0,0 +1,94 @@ +import { raiseError } from 'analytics' +import { GITHUB_OAUTH } from 'env' +import { errors } from 'platforms' +import { JSONRequest } from 'utils/general' + +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 { + 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://api.github.com/repos/${userName}/${repoName}` + return await request(url, { accessToken }) +} + +export async function getTreeData( + userName: string, + repoName: string, + branchName: string, + accessToken?: string, +): Promise { + const url = `https://api.github.com/repos/${userName}/${repoName}/git/trees/${branchName}?recursive=1` + return await request(url, { accessToken }) +} + +export async function getBlobData( + userName: string, + repoName: string, + sha: string, + accessToken?: string, +): Promise { + const url = `https://api.github.com/repos/${userName}/${repoName}/git/blobs/${sha}` + return await request(url, { accessToken }) +} + +export async function OAuth(code: string): Promise { + return await JSONRequest('https://github.com/login/oauth/access_token', { + code, + client_id: GITHUB_OAUTH.clientId, + client_secret: GITHUB_OAUTH.clientSecret, + }) +} diff --git a/src/platforms/GitHub/DOMHelper.ts b/src/platforms/GitHub/DOMHelper.ts new file mode 100644 index 0000000..bab58bd --- /dev/null +++ b/src/platforms/GitHub/DOMHelper.ts @@ -0,0 +1,191 @@ +import { raiseError } from 'analytics' +import { Clippy, ClippyClassName } from 'components/Clippy' +import { CopyFileButton, copyFileButtonClassName } from 'components/CopyFileButton' +import * as React from 'react' +import { $ } from 'utils/DOMHelper' +import { renderReact } from 'utils/general' + +export function isInCodePage() { + const branchListSelector = '#branch-select-menu.branch-select-menu' + return Boolean($(branchListSelector)) +} + +export function getCurrentBranch() { + const selectedBranchButtonSelector = '.repository-content .branch-select-menu summary' + const branchButtonElement: HTMLElement = $(selectedBranchButtonSelector) + if (branchButtonElement) { + const branchNameSpanElement = branchButtonElement.querySelector('span') + if (branchNameSpanElement) { + const partialBranchNameFromInnerText = branchNameSpanElement.innerText + if (!partialBranchNameFromInnerText.includes('…')) return partialBranchNameFromInnerText + } + const defaultTitle = 'Switch branches or tags' + const title = branchButtonElement.title.trim() + if (title !== defaultTitle && !title.includes(' ')) return title + } + + const findFileButtonSelector = + '#js-repo-pjax-container .repository-content .file-navigation a[data-hotkey="t"]' + const urlFromFindFileButton: string | undefined = $( + findFileButtonSelector, + element => (element as HTMLAnchorElement).href, + ) + if (urlFromFindFileButton) { + const commitPathRegex = /^(.*?)\/(.*?)\/find\/(.*?)$/ + const result = urlFromFindFileButton.match(commitPathRegex) + if (result) { + const [_, userName, repoName, branchName] = result + if (!branchName.includes(' ')) return branchName + } + } + + raiseError(new Error('cannot get current branch')) +} + +/** + * there are few types of pages on GitHub, mainly + * 1. raw text: code + * 2. rendered content: like Markdown + * 3. preview: like image + */ +const PAGE_TYPES = { + RAW_TEXT: 'raw_text', + RENDERED: 'rendered', + SEARCH: 'search', + // PREVIEW: 'preview', + OTHERS: 'others', +} + +/** + * this function tries to tell which type current page is of + * + * note: not determining through file extension here + * because there might be files using wrong extension name + * + * TODO: distinguish type 'preview' + */ +function getCurrentPageType() { + const blobPathSelector = '#blob-path' // path next to branch switcher + const blobWrapperSelector = '.repository-content .blob-wrapper table' + const readmeSelector = '.repository-content .readme' + const searchResultSelector = '.codesearch-results' + return ( + $(searchResultSelector, () => PAGE_TYPES.SEARCH) || + $(blobWrapperSelector, () => $(blobPathSelector, () => PAGE_TYPES.RAW_TEXT)) || + $(readmeSelector, () => PAGE_TYPES.RENDERED) || + PAGE_TYPES.OTHERS + ) +} + +const REPO_TYPE_PRIVATE = 'private' +const REPO_TYPE_PUBLIC = 'public' +export function getRepoPageType() { + const headerSelector = `#js-repo-pjax-container .pagehead.repohead h1` + return $(headerSelector, header => { + const repoPageTypes = [REPO_TYPE_PRIVATE, REPO_TYPE_PUBLIC] + for (const repoPageType of repoPageTypes) { + if (header.classList.contains(repoPageType)) { + return repoPageType + } + } + raiseError(new Error('cannot get repo page type')) + }) +} + +/** + * get text content of raw text content + */ +export function getCodeElement() { + if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) { + const codeContentSelector = '.repository-content .data table' + const codeContentElement = $(codeContentSelector) + if (!codeContentElement) { + raiseError(new Error('cannot find code content element')) + } + return codeContentElement + } +} + +/** + * add copy file content buttons to button groups + * click these buttons will copy file content to clipboard + */ +export function attachCopyFileBtn() { + if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) { + // the button group in file content header + const buttonGroupSelector = '.repository-content > .Box > .Box-header .BtnGroup' + const buttonGroups = document.querySelectorAll(buttonGroupSelector) + + if (buttonGroups.length === 0) { + raiseError(new Error(`No button groups found`)) + } + + buttonGroups.forEach(async buttonGroup => { + if (!buttonGroup.lastElementChild) return + const button = await renderReact(React.createElement(CopyFileButton)) + if (button instanceof HTMLElement) { + buttonGroup.appendChild(button) + } + }) + return () => { + const buttons = document.querySelectorAll(`.${copyFileButtonClassName}`) + buttons.forEach(button => { + button.parentElement?.removeChild(button) + }) + } + } +} + +export function attachCopySnippet() { + const readmeSelector = '.repository-content div#readme' + return $(readmeSelector, () => { + const readmeArticleSelector = '.repository-content div#readme article' + 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) + }) + } + }, + () => { + const plainReadmeSelector = '.repository-content div#readme .plain' + $(plainReadmeSelector, undefined, () => + raiseError( + new Error('cannot find mount point for copy snippet button while readme exists'), + ), + ) + }, + ) + }) +} diff --git a/src/platforms/GitHub/Request.d.ts b/src/platforms/GitHub/Request.d.ts new file mode 100644 index 0000000..19614fc --- /dev/null +++ b/src/platforms/GitHub/Request.d.ts @@ -0,0 +1,40 @@ +declare namespace GitHubAPI { + 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 + owner: { + html_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/utils/URLHelper.ts b/src/platforms/GitHub/URLHelper.ts similarity index 94% rename from src/utils/URLHelper.ts rename to src/platforms/GitHub/URLHelper.ts index 0907583..b67711b 100644 --- a/src/utils/URLHelper.ts +++ b/src/platforms/GitHub/URLHelper.ts @@ -1,7 +1,6 @@ import { raiseError } from 'analytics' -import { MetaData } from './GitHubHelper' -export function parse(): MetaData & { path: string[] } { +export function parse(): Partial & { path: string[] } { const { pathname } = window.location let [ , @@ -14,6 +13,7 @@ export function parse(): MetaData & { path: string[] } { return { userName, repoName, + branchName: undefined, type, path, } @@ -38,7 +38,7 @@ const TYPES = { // TODO: record more types } -export function isInCodePage(metaData: MetaData = {}) { +export function isInCodePage(metaData?: Partial) { const mergedRepo = { ...parse(), ...metaData } const { type, branchName } = mergedRepo return Boolean( diff --git a/src/platforms/GitHub/components.tsx b/src/platforms/GitHub/components.tsx new file mode 100644 index 0000000..eef51ba --- /dev/null +++ b/src/platforms/GitHub/components.tsx @@ -0,0 +1,39 @@ +import { GITHUB_OAUTH } from 'env' +import * as React from 'react' + +export function GitHubAccessDeniedError({ 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/GitHub/index.ts b/src/platforms/GitHub/index.ts new file mode 100644 index 0000000..5e70719 --- /dev/null +++ b/src/platforms/GitHub/index.ts @@ -0,0 +1,179 @@ +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: GitHubAPI.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: GitHubAPI.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://github.com/${userName}/${repoName}/${type}/${branchName}/${path}` +} + +export const GitHub: Platform = { + resolveMeta() { + if (!URLHelper.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?.owner?.html_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 useGitHubAttachCopySnippetButton(copySnippetButton: boolean) { + const attachCopySnippetButton = React.useCallback( + function attachCopySnippetButton() { + if (platform !== GitHub) return + if (copySnippetButton) return DOMHelper.attachCopySnippet() || undefined // for the sake of react effect + }, + [copySnippetButton], + ) + React.useEffect(attachCopySnippetButton, [copySnippetButton]) + useEvent('pjax:complete', attachCopySnippetButton, window) +} + +export function useGitHubAttachCopyFileButton(copyFileButton: boolean) { + const attachCopyFileButton = React.useCallback( + function attachCopyFileButton() { + if (platform !== GitHub) return + if (copyFileButton) return DOMHelper.attachCopyFileBtn() || undefined // for the sake of react effect + }, + [copyFileButton], + ) + React.useEffect(attachCopyFileButton, [copyFileButton]) + useEvent('pjax:complete', attachCopyFileButton, window) +} diff --git a/src/platforms/dummyPlatformForTypeSafety.ts b/src/platforms/dummyPlatformForTypeSafety.ts new file mode 100644 index 0000000..19dc929 --- /dev/null +++ b/src/platforms/dummyPlatformForTypeSafety.ts @@ -0,0 +1,16 @@ +export const dummyPlatformForTypeSafety: Platform = { + resolveMeta() { + return null + }, + getMetaData: callingDummyPlatformMethods, + getTreeData: callingDummyPlatformMethods, + shouldShow() { + return false + }, + getCurrentPath: callingDummyPlatformMethods, + setOAuth: callingDummyPlatformMethods, +} + +function callingDummyPlatformMethods(): any { + throw new Error(`Do not call dummy platform methods`) +} diff --git a/src/platforms/index.ts b/src/platforms/index.ts new file mode 100644 index 0000000..fa5f96f --- /dev/null +++ b/src/platforms/index.ts @@ -0,0 +1,27 @@ +import { dummyPlatformForTypeSafety } from './dummyPlatformForTypeSafety' +import { GitHub } from './GitHub' + +const hosts: Record<'GitHub' | 'GitLab' | 'Gitee', string[]> = { + GitHub: ['github.com'], + GitLab: ['gitlab.com'], + Gitee: ['gitee.com'], +} +function isGitHub() { + return hosts.GitHub.includes(window.location.host) +} + +function resolvePlatform(): Platform { + if (isGitHub()) return GitHub + return dummyPlatformForTypeSafety +} + +export const platform: Platform = resolvePlatform() + +export const errors = { + SERVER_FAULT: 'Server Fault', + NOT_FOUND: 'Repo Not Found', + BAD_CREDENTIALS: 'Bad credentials', + API_RATE_LIMIT: 'API rate limit', + EMPTY_PROJECT: 'Empty project', + BLOCKED_PROJECT: 'Blocked project', +} diff --git a/src/platforms/platform.d.ts b/src/platforms/platform.d.ts new file mode 100644 index 0000000..c88bc27 --- /dev/null +++ b/src/platforms/platform.d.ts @@ -0,0 +1,8 @@ +type Platform = { + resolveMeta(): MetaData | null + getMetaData(metaData: MetaData, accessToken?: string): Promise + getTreeData(metaData: MetaData, accessToken?: string): Promise + shouldShow(metaData?: Partial): boolean + getCurrentPath(branchName: string): string[] | null + setOAuth(code: string): Promise +} diff --git a/src/utils/DOMHelper.ts b/src/utils/DOMHelper.ts index c072c4b..6da363e 100644 --- a/src/utils/DOMHelper.ts +++ b/src/utils/DOMHelper.ts @@ -1,15 +1,6 @@ /** * this helper helps manipulating DOM */ -import { raiseError } from 'analytics' -import { Clippy, ClippyClassName } from 'components/Clippy' -import { CopyFileButton, copyFileButtonClassName } from 'components/CopyFileButton' -import * as NProgress from 'nprogress' -import * as PJAX from 'pjax' -import * as React from 'react' -import { renderReact } from './general' - -NProgress.configure({ showSpinner: false }) /** * when gitako is ready, make page's header narrower @@ -35,7 +26,7 @@ export function setBodyIndent(shouldShowGitako: boolean) { } } -function $ any, O extends () => any>( +export function $ any, O extends () => any>( selector: string, existCallback?: E, otherwise?: O, @@ -53,49 +44,6 @@ function $ any, O extends () => a return otherwise ? otherwise() : null } -export function isInCodePage() { - const branchListSelector = '#branch-select-menu.branch-select-menu' - return Boolean($(branchListSelector)) -} - -export function getBranches() { - const branchSelector = '.branch-select-menu .select-menu-list > div .select-menu-item-text' - const branchElements = Array.from(document.querySelectorAll(branchSelector)) - return branchElements.map(element => element.innerHTML.trim()) -} - -export function getCurrentBranch() { - const selectedBranchButtonSelector = '.repository-content .branch-select-menu summary' - const branchButtonElement: HTMLElement = $(selectedBranchButtonSelector) - if (branchButtonElement) { - const branchNameSpanElement = branchButtonElement.querySelector('span') - if (branchNameSpanElement) { - const partialBranchNameFromInnerText = branchNameSpanElement.innerText - if (!partialBranchNameFromInnerText.includes('…')) return partialBranchNameFromInnerText - } - const defaultTitle = 'Switch branches or tags' - const title = branchButtonElement.title.trim() - if (title !== defaultTitle && !title.includes(' ')) return title - } - - const findFileButtonSelector = - '#js-repo-pjax-container .repository-content .file-navigation a[data-hotkey="t"]' - const urlFromFindFileButton: string | undefined = $( - findFileButtonSelector, - element => (element as HTMLAnchorElement).href, - ) - if (urlFromFindFileButton) { - const commitPathRegex = /^(.*?)\/(.*?)\/find\/(.*?)$/ - const result = urlFromFindFileButton.match(commitPathRegex) - if (result) { - const [_, userName, repoName, branchName] = result - if (!branchName.includes(' ')) return branchName - } - } - - raiseError(new Error('cannot get current branch')) -} - /** * add the logo element into DOM */ @@ -123,123 +71,6 @@ export function scrollToRepoContent() { ) } -const pjax = new PJAX({ - elements: 'match-nothing-selector', - selectors: [ - '.repository-content', - 'title', - '[data-pjax="#js-repo-pjax-container"]', - '.page-content', - ], - scrollTo: false, - analytics: false, - cacheBust: false, - forceCache: true, // TODO: merge namespace, add forceCache -}) - -// Note: shall not enable below pjax:send listener as there would be dual bar when GitHub PJAX links are triggered -// window.addEventListener('pjax:send', () => mountTopProgressBar()) -window.addEventListener('pjax:complete', () => unmountTopProgressBar()) - -export function loadWithPJAX(URL: string) { - mountTopProgressBar() - pjax.loadUrl(URL, { scrollTo: 0 }) -} - -/** - * there are few types of pages on GitHub, mainly - * 1. raw text: code - * 2. rendered content: like Markdown - * 3. preview: like image - */ -const PAGE_TYPES = { - RAW_TEXT: 'raw_text', - RENDERED: 'rendered', - SEARCH: 'search', - // PREVIEW: 'preview', - OTHERS: 'others', -} - -/** - * this function tries to tell which type current page is of - * - * note: not determining through file extension here - * because there might be files using wrong extension name - * - * TODO: distinguish type 'preview' - */ -export function getCurrentPageType() { - const blobPathSelector = '#blob-path' // path next to branch switcher - const blobWrapperSelector = '.repository-content .blob-wrapper table' - const readmeSelector = '.repository-content .readme' - const searchResultSelector = '.codesearch-results' - return ( - $(searchResultSelector, () => PAGE_TYPES.SEARCH) || - $(blobWrapperSelector, () => $(blobPathSelector, () => PAGE_TYPES.RAW_TEXT)) || - $(readmeSelector, () => PAGE_TYPES.RENDERED) || - PAGE_TYPES.OTHERS - ) -} - -export const REPO_TYPE_PRIVATE = 'private' -export const REPO_TYPE_PUBLIC = 'public' -export function getRepoPageType() { - const headerSelector = `#js-repo-pjax-container .pagehead.repohead h1` - return $(headerSelector, header => { - const repoPageTypes = [REPO_TYPE_PRIVATE, REPO_TYPE_PUBLIC] - for (const repoPageType of repoPageTypes) { - if (header.classList.contains(repoPageType)) { - return repoPageType - } - } - raiseError(new Error('cannot get repo page type')) - }) -} - -/** - * get text content of raw text content - */ -export function getCodeElement() { - if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) { - const codeContentSelector = '.repository-content .data table' - const codeContentElement = $(codeContentSelector) - if (!codeContentElement) { - raiseError(new Error('cannot find code content element')) - } - return codeContentElement - } -} - -/** - * add copy file content buttons to button groups - * click these buttons will copy file content to clipboard - */ -export function attachCopyFileBtn() { - if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) { - // the button group in file content header - const buttonGroupSelector = '.repository-content > .Box > .Box-header .BtnGroup' - const buttonGroups = document.querySelectorAll(buttonGroupSelector) - - if (buttonGroups.length === 0) { - raiseError(new Error(`No button groups found`)) - } - - buttonGroups.forEach(async buttonGroup => { - if (!buttonGroup.lastElementChild) return - const button = await renderReact(React.createElement(CopyFileButton)) - if (button instanceof HTMLElement) { - buttonGroup.appendChild(button) - } - }) - return () => { - const buttons = document.querySelectorAll(`.${copyFileButtonClassName}`) - buttons.forEach(button => { - button.parentElement?.removeChild(button) - }) - } - } -} - /** * copy content of a DOM element to clipboard */ @@ -256,60 +87,6 @@ export function copyElementContent(element: Element): boolean { return isCopySuccessful } -export function attachCopySnippet() { - const readmeSelector = '.repository-content div#readme' - return $(readmeSelector, () => { - const readmeArticleSelector = '.repository-content div#readme article' - 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) - }) - } - }, - () => { - const plainReadmeSelector = '.repository-content div#readme .plain' - $(plainReadmeSelector, undefined, () => - raiseError( - new Error('cannot find mount point for copy snippet button while readme exists'), - ), - ) - }, - ) - }) -} - /** * focus to side bar, user will be able to manipulate it with keyboard */ @@ -331,11 +108,3 @@ export function focusSearchInput() { } }) } - -export function mountTopProgressBar() { - NProgress.start() -} - -export function unmountTopProgressBar() { - NProgress.done() -} diff --git a/src/utils/GitHubHelper.ts b/src/utils/GitHubHelper.ts deleted file mode 100644 index d11a4cc..0000000 --- a/src/utils/GitHubHelper.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { raiseError } from 'analytics' -export const SERVER_FAULT = 'Server Fault' -export const NOT_FOUND = 'Repo Not Found' -export const BAD_CREDENTIALS = 'Bad credentials' -export const API_RATE_LIMIT = `API rate limit` -export const EMPTY_PROJECT = `Empty project` -export const BLOCKED_PROJECT = `Blocked project` - -function apiRateLimitExceeded(content: any /* examined any */) { - return ( - content && content['documentation_url'] === 'https://developer.github.com/v3/#rate-limiting' - ) -} - -function isEmptyProject(content: any /* examined any */) { - return content && content['message'] === 'Git Repository is empty.' -} - -function isBlockedProject(content: any /* examined any */) { - return content && content['message'] === 'Repository access blocked' -} - -type Options = { - accessToken?: string -} - -async function request(url: string, { accessToken }: Options = {}) { - 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 { - if (res.status === 404 || res.status === 401) throw new Error(NOT_FOUND) - else if (res.status === 500) throw new Error(SERVER_FAULT) - else { - const content = await res.json() - if (apiRateLimitExceeded(content)) throw new Error(API_RATE_LIMIT) - if (isEmptyProject(content)) throw new Error(EMPTY_PROJECT) - if (isBlockedProject(content)) throw new Error(BLOCKED_PROJECT) - // Unknown type of error, report it! - raiseError(new Error(res.statusText)) - throw new Error(content && content.message) - } - } -} - -type PageType = 'blob' | 'tree' | string - -export type MetaData = { - userName?: string - repoName?: string - branchName?: string - accessToken?: string - type?: PageType - api?: RepoMetaData -} - -type RepoMetaData = { - default_branch: string - html_url: string - owner: { - html_url: string - } -} - -export async function getRepoMeta({ - userName, - repoName, - accessToken, -}: MetaData): Promise { - const url = `https://api.github.com/repos/${userName}/${repoName}` - return await request(url, { accessToken }) -} - -export type TreeItem = { - path: string - mode: string - sha: string - size: number - url: string - type: 'blob' | 'commit' | 'tree' -} - -export type TreeData = { - sha: string - truncated: boolean - tree: TreeItem[] - url: string -} - -export async function getTreeData({ - userName, - repoName, - branchName, - accessToken, -}: MetaData): Promise { - const url = `https://api.github.com/repos/${userName}/${repoName}/git/trees/${branchName}?recursive=1` - return await request(url, { accessToken }) -} - -export type BlobData = { - encoding: 'base64' | string - sha: string - content?: string - size: number - url: string -} - -export async function getBlobData({ - userName, - repoName, - accessToken, - sha, -}: Pick & { - sha: string -}): Promise { - const url = `https://api.github.com/repos/${userName}/${repoName}/git/blobs/${sha}` - return await request(url, { accessToken }) -} - -export function getUrlForRedirect( - { userName, repoName, branchName }: MetaData, - type = 'blob', - path?: string, -) { - return `https://github.com/${userName}/${repoName}/${type}/${branchName}/${path}` -} diff --git a/src/utils/VisibleNodesGenerator.ts b/src/utils/VisibleNodesGenerator.ts index bc8dc9e..ba9415d 100644 --- a/src/utils/VisibleNodesGenerator.ts +++ b/src/utils/VisibleNodesGenerator.ts @@ -21,16 +21,6 @@ import { findNode } from './general' * v stable */ -export type TreeNode = { - name: string - contents?: TreeNode[] - path: string - url?: string - sha?: string - type: 'tree' | 'blob' | 'commit' - accessDenied?: boolean -} - function filterDuplications(arr: T[]) { return Array.from(new Set(arr)) } diff --git a/src/utils/general.ts b/src/utils/general.ts index 0b4e218..a18bacc 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -1,6 +1,5 @@ import { ReactElement } from 'react' import * as ReactDOM from 'react-dom' -import { TreeNode } from './VisibleNodesGenerator' export function pick(source: T, keys: string[]): Partial { if (keys && typeof keys === 'object') { diff --git a/src/utils/gitSubmodule.ts b/src/utils/gitSubmodule.ts new file mode 100644 index 0000000..b1fa06d --- /dev/null +++ b/src/utils/gitSubmodule.ts @@ -0,0 +1,78 @@ +import * as ini from 'ini' +import { findNode } from 'utils/general' + +const subModuleURLRegex = { + HTTP: /^https?:\/\/.*?$/, + HTTPGit: /^https:.*?\.git$/, + git: /^git@.*?:(.*?)\.git$/, +} + +function transformModuleGitURL(node: TreeNode, URL: string) { + const matched = URL.match(subModuleURLRegex.git) + if (!matched) return + const [_, userName, repoName] = matched + return appendCommitPath(`https://github.com/${userName}/${repoName}`, node) +} + +function cutDotGit(URL: string) { + return URL.replace(/\.git$/, '') +} + +function appendCommitPath(URL: string, node: TreeNode) { + return URL.replace(/\/?$/, `/tree/${node.sha}`) +} + +function transformModuleHTTPDotGitURL(node: TreeNode, URL: string) { + return appendCommitPath(cutDotGit(URL), node) +} + +function transformModuleHTTPURL(node: TreeNode, URL: string) { + return appendCommitPath(URL, node) +} + +type ParsedINI = { + [key: string]: ParsedModule | ParsedINI | undefined +} + +type ParsedModule = { + [key: string]: string | undefined +} + +function handleParsed(root: TreeNode, parsed: ParsedINI) { + Object.values(parsed).forEach(value => { + if (typeof value === 'string') return + const url = value?.url + const path = value?.path + if (typeof url === 'string' && typeof path === 'string') { + const node = findNode(root, path.split('/')) + if (node) { + if (subModuleURLRegex.HTTPGit.test(url)) { + node.url = transformModuleHTTPDotGitURL(node, url) + } else if (subModuleURLRegex.git.test(url)) { + node.url = transformModuleGitURL(node, url) + } else if (subModuleURLRegex.HTTP.test(url)) { + node.url = transformModuleHTTPURL(node, url) + } else { + node.accessDenied = true + } + } else { + // It turns out that we did not miss any submodule after a lot of tests. + // Commenting this. + // raiseError(new Error(`Submodule node not found`), { path }) + } + } else { + handleParsed(root, value as ParsedINI) + } + }) +} + +export function resolveGitModules(root: TreeNode, content: string) { + try { + if (Array.isArray(root.contents)) { + const parsed: ParsedINI = ini.parse(content) + handleParsed(root, parsed) + } + } catch (err) { + throw new Error(`Error resolving git modules`) + } +} diff --git a/src/utils/hooks/usePJAX.ts b/src/utils/hooks/usePJAX.ts new file mode 100644 index 0000000..090bc06 --- /dev/null +++ b/src/utils/hooks/usePJAX.ts @@ -0,0 +1,39 @@ +import * as PJAX from 'pjax' +import * as React from 'react' +import { useProgressBar } from './useProgressBar' + +export function usePJAX() { + // Note: shall not enable below pjax:send listener as there would be dual bar when GitHub PJAX links are triggered + // window.addEventListener('pjax:send', () => mountTopProgressBar()) + const [pjax] = React.useState( + () => + new PJAX({ + elements: 'match-nothing-selector', + selectors: [ + '.repository-content', + 'title', + '[data-pjax="#js-repo-pjax-container"]', + '.page-content', + ], + scrollTo: false, + analytics: false, + cacheBust: false, + forceCache: true, // TODO: merge namespace, add forceCache + }), + ) + const progressBar = useProgressBar() + React.useEffect(() => { + window.addEventListener('pjax:complete', progressBar.unmount) + return () => window.removeEventListener('pjax:complete', progressBar.unmount) + }, []) + + const loadWithPJAX = React.useCallback( + function loadWithPJAX(URL: string) { + progressBar.mount() + pjax.loadUrl(URL, { scrollTo: 0 }) + }, + [pjax], + ) + + return loadWithPJAX +} diff --git a/src/utils/hooks/useProgressBar.ts b/src/utils/hooks/useProgressBar.ts new file mode 100644 index 0000000..0e2959a --- /dev/null +++ b/src/utils/hooks/useProgressBar.ts @@ -0,0 +1,22 @@ +import * as NProgress from 'nprogress' +import * as React from 'react' + +export function useProgressBar() { + const [progressBar] = React.useState(() => { + return { + mount() { + NProgress.start() + }, + + unmount() { + NProgress.done() + }, + } + }) + + React.useEffect(() => { + NProgress.configure({ showSpinner: false }) + }, []) + + return progressBar +} diff --git a/src/utils/parseIconMapCSV.ts b/src/utils/parseIconMapCSV.ts index 96c42e5..530d15f 100644 --- a/src/utils/parseIconMapCSV.ts +++ b/src/utils/parseIconMapCSV.ts @@ -1,6 +1,5 @@ import rawFileIconIndex from 'assets/icons/file-icons-index.csv' import rawFolderIconIndex from 'assets/icons/folder-icons-index.csv' -import { TreeNode } from 'utils/VisibleNodesGenerator' function parseFileIconMapCSV() { const filenameIndex = new Map() diff --git a/src/utils/treeParser.ts b/src/utils/treeParser.ts index f817fd9..de5786b 100644 --- a/src/utils/treeParser.ts +++ b/src/utils/treeParser.ts @@ -1,32 +1,15 @@ -import { getUrlForRedirect, MetaData, TreeData } from 'utils/GitHubHelper' -import { TreeNode } from './VisibleNodesGenerator' - -type RawItem = Partial<{ - mode: string - path: string - sha: string - size: number - type: 'tree' | 'blob' | 'commit' - url: string -}> - -const revert = any>(f: T) => (...args: Parameters) => !f(...args) - const isFolder = (node: TreeNode) => node.type === 'tree' -const isNotFolder = revert(isFolder) -function sortFoldersToFront(root: TreeNode) { - function depthFirstSearch(root: TreeNode) { - const nodes = root.contents - if (nodes) { - nodes.splice(0, Infinity, ...nodes.filter(isFolder), ...nodes.filter(isNotFolder)) - nodes.forEach(depthFirstSearch) - } - return root +const isNotFolder = (node: TreeNode) => node.type !== 'tree' + +export function sortFoldersToFront(root: TreeNode) { + const nodes = root.contents + if (nodes) { + nodes.splice(0, Infinity, ...nodes.filter(isFolder), ...nodes.filter(isNotFolder)) + nodes.forEach(sortFoldersToFront) } - return depthFirstSearch(root) } -function findGitModules(root: TreeNode) { +export function findGitModules(root: TreeNode) { if (root.contents) { const modulesFile = root.contents.find(content => content.name === '.gitmodules') if (modulesFile) { @@ -35,57 +18,3 @@ function findGitModules(root: TreeNode) { } return null } - -export function parse(treeData: 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: RawItem[] = [] - 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, 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 - } - }) - - return { - gitModules: findGitModules(root), - root: sortFoldersToFront(root), - } -} From 23cc6ba5b7c0787537e0f0e650aa7bf8e25a5a9d Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sat, 28 Mar 2020 22:08:58 +0800 Subject: [PATCH 02/13] feat: resolve platform async --- src/content.tsx | 46 ++++++++++++++++++++++-------------------- src/platforms/index.ts | 14 +++++++++++-- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/content.tsx b/src/content.tsx index f4ed9b5..b65f0ab 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -1,33 +1,35 @@ import { withErrorLog } from 'analytics' import { Gitako } from 'components/Gitako' import { addMiddleware } from 'driver/connect' -import { platform } from 'platforms' +import { platform, resolvePlatformP } from 'platforms' import * as React from 'react' import * as ReactDOM from 'react-dom' import './content.scss' -if (platform.resolveMeta()) { - addMiddleware(withErrorLog) +resolvePlatformP.then(() => { + if (platform.resolveMeta()) { + addMiddleware(withErrorLog) - function init() { - // injects a copy of stylesheets so that other extensions(e.g. dark reader) could read - function injectStyles(url: string) { - var linkElement = document.createElement('link') - linkElement.rel = 'stylesheet' - linkElement.setAttribute('href', url) - document.head.appendChild(linkElement) + function init() { + // injects a copy of stylesheets so that other extensions(e.g. dark reader) could read + function injectStyles(url: string) { + var linkElement = document.createElement('link') + linkElement.rel = 'stylesheet' + linkElement.setAttribute('href', url) + document.head.appendChild(linkElement) + } + + injectStyles(browser.extension.getURL('content.css')) + + const SideBarElement = document.createElement('div') + document.body.appendChild(SideBarElement) + ReactDOM.render(, SideBarElement) } - injectStyles(browser.extension.getURL('content.css')) - - const SideBarElement = document.createElement('div') - document.body.appendChild(SideBarElement) - ReactDOM.render(, SideBarElement) + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init) + } else { + init() + } } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init) - } else { - init() - } -} +}) diff --git a/src/platforms/index.ts b/src/platforms/index.ts index fa5f96f..cd54f77 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -6,16 +6,26 @@ const hosts: Record<'GitHub' | 'GitLab' | 'Gitee', string[]> = { GitLab: ['gitlab.com'], Gitee: ['gitee.com'], } + function isGitHub() { return hosts.GitHub.includes(window.location.host) } -function resolvePlatform(): Platform { +async function resolvePlatform(): Promise { if (isGitHub()) return GitHub return dummyPlatformForTypeSafety } -export const platform: Platform = resolvePlatform() +let p: Platform = dummyPlatformForTypeSafety + +export const resolvePlatformP = resolvePlatform() +resolvePlatformP.then(platform => (p = platform)) + +export const platform: Platform = new Proxy(dummyPlatformForTypeSafety, { + get(target, key: keyof Platform) { + return p[key] + }, +}) export const errors = { SERVER_FAULT: 'Server Fault', From 0f1f129c0140ebb615a14d96b7bc990de19bf897 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 00:40:35 +0800 Subject: [PATCH 03/13] refactor: resolve platform async --- src/platforms/index.ts | 40 ++++++++++++++++++++++++++++---------- src/utils/configHelper.ts | 5 +++-- src/utils/storageHelper.ts | 8 ++++++-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/platforms/index.ts b/src/platforms/index.ts index cd54f77..f12dadd 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -1,29 +1,49 @@ +import * as storageHelper from 'utils/storageHelper' import { dummyPlatformForTypeSafety } from './dummyPlatformForTypeSafety' import { GitHub } from './GitHub' -const hosts: Record<'GitHub' | 'GitLab' | 'Gitee', string[]> = { - GitHub: ['github.com'], - GitLab: ['gitlab.com'], - Gitee: ['gitee.com'], +const platformsMap: Record< + 'GitHub' | 'GitLab' | 'Gitee', + { platform: Platform; hosts: string[] } +> = { + GitHub: { platform: GitHub, hosts: ['github.com'] }, + GitLab: { platform: GitHub, hosts: ['gitlab.com'] }, + Gitee: { platform: GitHub, hosts: ['gitee.com'] }, } -function isGitHub() { - return hosts.GitHub.includes(window.location.host) +const CustomDomainsStorageKey = 'CUSTOM_DOMAINS' +async function loadCustomDomains() { + const c = await storageHelper.get([CustomDomainsStorageKey]) + if (c) { + const { [CustomDomainsStorageKey]: customDomains } = c + if (customDomains) { + Object.keys(customDomains).forEach(domain => { + if (domain in platformsMap) { + platformsMap[domain as keyof typeof platformsMap].hosts.push(...customDomains[domain]) + } + }) + } + } } +loadCustomDomains() async function resolvePlatform(): Promise { - if (isGitHub()) return GitHub + for (const { hosts, platform } of Object.values(platformsMap)) { + if (hosts.some(host => host === window.location.host)) { + return platform + } + } return dummyPlatformForTypeSafety } -let p: Platform = dummyPlatformForTypeSafety +let $platform: Platform = dummyPlatformForTypeSafety export const resolvePlatformP = resolvePlatform() -resolvePlatformP.then(platform => (p = platform)) +resolvePlatformP.then(platform => ($platform = platform)) export const platform: Platform = new Proxy(dummyPlatformForTypeSafety, { get(target, key: keyof Platform) { - return p[key] + return $platform[key] }, }) diff --git a/src/utils/configHelper.ts b/src/utils/configHelper.ts index 61aab6f..95c0b04 100644 --- a/src/utils/configHelper.ts +++ b/src/utils/configHelper.ts @@ -35,7 +35,7 @@ export const defaultConfigs: Config = { const configKeyArray = Object.values(configKeys) -function applyDefaultConfigs(configs: Config) { +function applyDefaultConfigs(configs: Partial) { return configKeyArray.reduce((applied, configKey) => { const key = configKey as keyof Config Object.assign(applied, { [key]: key in configs ? configs[key] : defaultConfigs[key] }) @@ -44,7 +44,8 @@ function applyDefaultConfigs(configs: Config) { } export async function get(): Promise { - return applyDefaultConfigs(await storageHelper.get(configKeyArray)) + const config = await storageHelper.get(configKeyArray) + return applyDefaultConfigs(config || {}) } export async function set(partialConfig: Partial) { diff --git a/src/utils/storageHelper.ts b/src/utils/storageHelper.ts index 558a041..5125506 100644 --- a/src/utils/storageHelper.ts +++ b/src/utils/storageHelper.ts @@ -1,8 +1,12 @@ const localStorage = browser.storage.local -export function get(mapping: string[] | null): Promise | any { +export async function get< + T extends { + [key: string]: any + } +>(mapping: string[] | null): Promise { try { - return localStorage.get(mapping || undefined) + return (await localStorage.get(mapping || undefined)) as T } catch (err) {} } From 07b520db07cd64d9f2444e03263636c9b3b58edd Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 00:40:44 +0800 Subject: [PATCH 04/13] feat: setup gitee --- src/content.scss | 39 +++++++ src/env.ts | 5 + src/platforms/Gitee/API.ts | 117 ++++++++++++++++++++ src/platforms/Gitee/DOMHelper.ts | 83 ++++++++++++++ src/platforms/Gitee/Request.d.ts | 40 +++++++ src/platforms/Gitee/URLHelper.ts | 91 ++++++++++++++++ src/platforms/Gitee/components.tsx | 39 +++++++ src/platforms/Gitee/index.ts | 167 +++++++++++++++++++++++++++++ src/platforms/index.ts | 3 +- src/utils/hooks/usePJAX.ts | 1 + 10 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 src/platforms/Gitee/API.ts create mode 100644 src/platforms/Gitee/DOMHelper.ts create mode 100644 src/platforms/Gitee/Request.d.ts create mode 100644 src/platforms/Gitee/URLHelper.ts create mode 100644 src/platforms/Gitee/components.tsx create mode 100644 src/platforms/Gitee/index.ts 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, From 6a55eb3dc2555bf9fc9bc2b231b36298c8ec8a11 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 01:12:49 +0800 Subject: [PATCH 05/13] refactor: differ resizing by platforms --- src/components/Resizable.tsx | 16 ++------------ src/content.scss | 23 ++++++++++----------- src/platforms/GitHub/index.ts | 17 +++++++++++++++ src/platforms/Gitee/index.ts | 17 +++++++++++++++ src/platforms/dummyPlatformForTypeSafety.ts | 1 + src/platforms/platform.d.ts | 1 + 6 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/components/Resizable.tsx b/src/components/Resizable.tsx index d2002d8..2d6b594 100644 --- a/src/components/Resizable.tsx +++ b/src/components/Resizable.tsx @@ -1,11 +1,10 @@ import { HorizontalResizeHandler } from 'components/ResizeHandler' import { useConfigs } from 'containers/ConfigsContext' +import { platform } from 'platforms' import * as React from 'react' import { useWindowSize } from 'react-use' import { cx } from 'utils/cx' -import { bodySpacingClassName } from 'utils/DOMHelper' import * as features from 'utils/features' -import { useMediaStyleSheet } from 'utils/hooks/useMediaStyleSheet' export type Size = number type Props = { @@ -14,7 +13,6 @@ type Props = { } const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100 -const GITHUB_WIDTH = 1020 export function Resizable({ baseSize, className, children }: React.PropsWithChildren) { const [size, setSize] = React.useState(baseSize) @@ -35,17 +33,7 @@ export function Resizable({ baseSize, className, children }: React.PropsWithChil configContext.set({ sideBarWidth: size }) }, [size]) - useMediaStyleSheet( - `.${bodySpacingClassName} { margin-left: calc(var(--gitako-width) * 2 + 1020px - 100vw); }`, - size => [`min-width: ${size + GITHUB_WIDTH}px`, `max-width: ${size * 2 + GITHUB_WIDTH}px`], - size, - ) - - useMediaStyleSheet( - `.${bodySpacingClassName} { margin-left: var(--gitako-width); }`, - size => [`max-width: ${size + GITHUB_WIDTH}px`], - size, - ) + platform.useResizeStylesheets(size) const onResize = React.useCallback((size: number) => { if (size < window.innerWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH) setSize(size) diff --git a/src/content.scss b/src/content.scss index 11351f4..8427ea4 100644 --- a/src/content.scss +++ b/src/content.scss @@ -35,6 +35,8 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- &.git-project { #git-header-nav { position: static; + padding-left: 0; + padding-right: 0; } padding-top: 0; @@ -44,19 +46,16 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- .with-gitako-spacing { // gitee &.git-project { - // width: auto; // shrink width - .ui.container { - // width: auto; - } + width: auto; // shrink width .site-content { - // min-width: auto; - // max-width: 1020px; + min-width: 1040px; } } } @media (min-width: $github-content-width) { - body { + // github + body.env-production { min-width: $github-content-width; } } @@ -541,19 +540,19 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- } } } +} - // gitee - body.git-project { - // reset styles +// gitee +.git-project { + // reset styles + .#{$name}-side-bar { input[type='text'], input[type='password'], .ui-autocomplete-input, textarea, .uneditable-input { padding: initial; - // line-height: 18px; border: none; - // border-radius: 3px; } } } diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index 5e70719..1453b81 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -1,7 +1,9 @@ import { platform } from 'platforms' import * as React from 'react' import { useEvent } from 'react-use' +import { bodySpacingClassName } from 'utils/DOMHelper' import { resolveGitModules } from 'utils/gitSubmodule' +import { useMediaStyleSheet } from 'utils/hooks/useMediaStyleSheet' import { sortFoldersToFront } from 'utils/treeParser' import * as API from './API' import * as DOMHelper from './DOMHelper' @@ -152,6 +154,7 @@ export const GitHub: Platform = { } return accessToken }, + useResizeStylesheets, } export function useGitHubAttachCopySnippetButton(copySnippetButton: boolean) { @@ -177,3 +180,17 @@ export function useGitHubAttachCopyFileButton(copyFileButton: boolean) { React.useEffect(attachCopyFileButton, [copyFileButton]) useEvent('pjax:complete', attachCopyFileButton, window) } + +function useResizeStylesheets(size: number) { + const CONTENT_WIDTH = 1020 + useMediaStyleSheet( + `.${bodySpacingClassName} { margin-left: calc(var(--gitako-width) * 2 + 1020px - 100vw); }`, + size => [`min-width: ${size + CONTENT_WIDTH}px`, `max-width: ${size * 2 + CONTENT_WIDTH}px`], + size, + ) + useMediaStyleSheet( + `.${bodySpacingClassName} { margin-left: var(--gitako-width); }`, + size => [`max-width: ${size + CONTENT_WIDTH}px`], + size, + ) +} diff --git a/src/platforms/Gitee/index.ts b/src/platforms/Gitee/index.ts index 1336572..c9c46b0 100644 --- a/src/platforms/Gitee/index.ts +++ b/src/platforms/Gitee/index.ts @@ -1,7 +1,9 @@ import { platform } from 'platforms' import * as React from 'react' import { useEvent } from 'react-use' +import { bodySpacingClassName } from 'utils/DOMHelper' import { resolveGitModules } from 'utils/gitSubmodule' +import { useMediaStyleSheet } from 'utils/hooks/useMediaStyleSheet' import { sortFoldersToFront } from 'utils/treeParser' import * as API from './API' import * as DOMHelper from './DOMHelper' @@ -152,6 +154,7 @@ export const Gitee: Platform = { } return accessToken }, + useResizeStylesheets, } export function useGiteeAttachCopySnippetButton(copySnippetButton: boolean) { @@ -165,3 +168,17 @@ export function useGiteeAttachCopySnippetButton(copySnippetButton: boolean) { React.useEffect(attachCopySnippetButton, [copySnippetButton]) useEvent('pjax:complete', attachCopySnippetButton, window) } + +function useResizeStylesheets(size: number) { + const CONTENT_WIDTH = 1040 + useMediaStyleSheet( + `.${bodySpacingClassName} { margin-left: calc(var(--gitako-width) * 2 + ${CONTENT_WIDTH}px - 100vw); }`, + size => [`min-width: ${size + CONTENT_WIDTH}px`, `max-width: ${size * 2 + CONTENT_WIDTH}px`], + size, + ) + useMediaStyleSheet( + `.${bodySpacingClassName} { margin-left: var(--gitako-width); }`, + size => [`max-width: ${size + CONTENT_WIDTH}px`], + size, + ) +} diff --git a/src/platforms/dummyPlatformForTypeSafety.ts b/src/platforms/dummyPlatformForTypeSafety.ts index 19dc929..6d33ceb 100644 --- a/src/platforms/dummyPlatformForTypeSafety.ts +++ b/src/platforms/dummyPlatformForTypeSafety.ts @@ -9,6 +9,7 @@ export const dummyPlatformForTypeSafety: Platform = { }, getCurrentPath: callingDummyPlatformMethods, setOAuth: callingDummyPlatformMethods, + useResizeStylesheets: callingDummyPlatformMethods, } function callingDummyPlatformMethods(): any { diff --git a/src/platforms/platform.d.ts b/src/platforms/platform.d.ts index c88bc27..6f74eb4 100644 --- a/src/platforms/platform.d.ts +++ b/src/platforms/platform.d.ts @@ -5,4 +5,5 @@ type Platform = { shouldShow(metaData?: Partial): boolean getCurrentPath(branchName: string): string[] | null setOAuth(code: string): Promise + useResizeStylesheets(size: number): void } From 5e5b54132e24ed1ad382b0bd66c5a325823e2af2 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 15:48:23 +0800 Subject: [PATCH 06/13] feat: platform getOAuthLink --- .../settings/AccessTokenSettings.tsx | 10 ++-- src/components/settings/FileTreeSettings.tsx | 2 +- src/components/{ => settings}/SettingsBar.tsx | 60 +++++++++++-------- src/platforms/GitHub/index.ts | 6 ++ src/platforms/Gitee/index.ts | 8 ++- src/platforms/platform.d.ts | 3 +- 6 files changed, 54 insertions(+), 35 deletions(-) rename src/components/{ => settings}/SettingsBar.tsx (63%) diff --git a/src/components/settings/AccessTokenSettings.tsx b/src/components/settings/AccessTokenSettings.tsx index 9846326..6986f0f 100644 --- a/src/components/settings/AccessTokenSettings.tsx +++ b/src/components/settings/AccessTokenSettings.tsx @@ -1,7 +1,7 @@ import { Button, TextInput } from '@primer/components' -import { wikiLinks } from 'components/SettingsBar' +import { wikiLinks } from 'components/settings/SettingsBar' import { useConfigs } from 'containers/ConfigsContext' -import { GITHUB_OAUTH } from 'env' +import { usePlatform } from 'containers/PlatformContext' import * as React from 'react' import { useStates } from 'utils/hooks/useStates' import { SettingsSection } from './SettingsSection' @@ -16,6 +16,7 @@ export function AccessTokenSettings(props: React.PropsWithChildren) { const useAccessToken = useStates('') const useAccessTokenHint = useStates('') const focusInput = useStates(false) + const platform = usePlatform() const { val: accessTokenHint } = useAccessTokenHint const { val: accessToken } = useAccessToken @@ -78,10 +79,7 @@ export function AccessTokenSettings(props: React.PropsWithChildren) { className={'link-button'} onClick={() => { // use js here to make sure redirect_uri is latest url - const url = `https://github.com/login/oauth/authorize?client_id=${ - GITHUB_OAUTH.clientId - }&scope=repo&redirect_uri=${encodeURIComponent(window.location.href)}` - window.location.href = url + window.location.href = platform.getOAuthLink() }} > Create with OAuth (recommended) diff --git a/src/components/settings/FileTreeSettings.tsx b/src/components/settings/FileTreeSettings.tsx index f47ea61..abef626 100644 --- a/src/components/settings/FileTreeSettings.tsx +++ b/src/components/settings/FileTreeSettings.tsx @@ -1,4 +1,4 @@ -import { wikiLinks } from 'components/SettingsBar' +import { wikiLinks } from 'components/settings/SettingsBar' import { SimpleToggleField } from 'components/SimpleToggleField' import { useConfigs } from 'containers/ConfigsContext' import * as React from 'react' diff --git a/src/components/SettingsBar.tsx b/src/components/settings/SettingsBar.tsx similarity index 63% rename from src/components/SettingsBar.tsx rename to src/components/settings/SettingsBar.tsx index 3a966b0..ceb222a 100644 --- a/src/components/SettingsBar.tsx +++ b/src/components/settings/SettingsBar.tsx @@ -1,13 +1,15 @@ import { Link } from '@primer/components' import { Icon } from 'components/Icon' +import { usePlatform } from 'containers/PlatformContext' import { VERSION } from 'env' +import { GitHub } from 'platforms/GitHub' import * as React from 'react' import { useStates } from 'utils/hooks/useStates' -import { AccessTokenSettings } from './settings/AccessTokenSettings' -import { FileTreeSettings } from './settings/FileTreeSettings' -import { SettingsSection } from './settings/SettingsSection' -import { SidebarSettings } from './settings/SidebarSettings' -import { SimpleField, SimpleToggleField } from './SimpleToggleField' +import { SimpleField, SimpleToggleField } from '../SimpleToggleField' +import { AccessTokenSettings } from './AccessTokenSettings' +import { FileTreeSettings } from './FileTreeSettings' +import { SettingsSection } from './SettingsSection' +import { SidebarSettings } from './SidebarSettings' const WIKI_HOME_LINK = 'https://github.com/EnixCoda/Gitako/wiki' export const wikiLinks = { @@ -23,23 +25,27 @@ type Props = { toggleShowSettings: () => void } -const moreFields: SimpleField[] = [ - { - key: 'copyFileButton', - label: 'Copy file shortcut', - wikiLink: wikiLinks.copyFileButton, - }, - { - key: 'copySnippetButton', - label: 'Copy snippet shortcut', - wikiLink: wikiLinks.copySnippet, - }, -] - function SettingsBarContent() { const useReloadHint = useStates('') const { val: reloadHint } = useReloadHint + const platform = usePlatform() + const moreFields: SimpleField[] = + platform === GitHub + ? [ + { + key: 'copyFileButton', + label: 'Copy file shortcut', + wikiLink: wikiLinks.copyFileButton, + }, + { + key: 'copySnippetButton', + label: 'Copy snippet shortcut', + wikiLink: wikiLinks.copySnippet, + }, + ] + : [] + return ( <>

Settings

@@ -48,15 +54,17 @@ function SettingsBarContent() { - - {moreFields.map(field => ( - - - - ))} + {moreFields.length > 0 && ( + + {moreFields.map(field => ( + + + + ))} - {reloadHint &&
{reloadHint}
} -
+ {reloadHint &&
{reloadHint}
} +
+ )} Report bug / Request feature. diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index 1453b81..4e26ae9 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -1,4 +1,5 @@ import { platform } from 'platforms' +import { GITHUB_OAUTH } from 'env' import * as React from 'react' import { useEvent } from 'react-use' import { bodySpacingClassName } from 'utils/DOMHelper' @@ -155,6 +156,11 @@ export const GitHub: Platform = { return accessToken }, useResizeStylesheets, + getOAuthLink() { + return `https://github.com/login/oauth/authorize?client_id=${ + GITHUB_OAUTH.clientId + }&scope=repo&redirect_uri=${encodeURIComponent(window.location.href)}` + }, } export function useGitHubAttachCopySnippetButton(copySnippetButton: boolean) { diff --git a/src/platforms/Gitee/index.ts b/src/platforms/Gitee/index.ts index c9c46b0..89df05d 100644 --- a/src/platforms/Gitee/index.ts +++ b/src/platforms/Gitee/index.ts @@ -1,4 +1,5 @@ import { platform } from 'platforms' +import { GITEE_OAUTH } from 'env' import * as React from 'react' import { useEvent } from 'react-use' import { bodySpacingClassName } from 'utils/DOMHelper' @@ -104,7 +105,7 @@ export const Gitee: Platform = { userName, repoName, branchName, - userUrl: data?.parent?.url, + userUrl: data?.html_url?.replace(/(.*)\/.*?$/, '$1'), repoUrl: data?.html_url, } @@ -155,6 +156,11 @@ export const Gitee: Platform = { return accessToken }, useResizeStylesheets, + getOAuthLink() { + return `https://gitee.com/oauth/authorize?client_id=${ + GITEE_OAUTH.clientId + }&scope=repo&response_type=code&redirect_uri=${encodeURIComponent(window.location.href)}` + }, } export function useGiteeAttachCopySnippetButton(copySnippetButton: boolean) { diff --git a/src/platforms/platform.d.ts b/src/platforms/platform.d.ts index 6f74eb4..95c6bf5 100644 --- a/src/platforms/platform.d.ts +++ b/src/platforms/platform.d.ts @@ -4,6 +4,7 @@ type Platform = { getTreeData(metaData: MetaData, accessToken?: string): Promise shouldShow(metaData?: Partial): boolean getCurrentPath(branchName: string): string[] | null - setOAuth(code: string): Promise useResizeStylesheets(size: number): void + setOAuth(code: string): Promise + getOAuthLink(): string } From f379d0ebe6ed2751f689a4abb8b434082f3f5d68 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 15:49:05 +0800 Subject: [PATCH 07/13] feat: platform context --- src/components/FileExplorer.tsx | 3 ++- src/components/Gitako.tsx | 13 ++++++++----- src/components/Resizable.tsx | 3 ++- src/components/SideBar.tsx | 4 +++- src/containers/PlatformContext.tsx | 17 +++++++++++++++++ src/platforms/GitHub/index.ts | 4 +++- src/platforms/Gitee/index.ts | 3 ++- 7 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 src/containers/PlatformContext.tsx diff --git a/src/components/FileExplorer.tsx b/src/components/FileExplorer.tsx index a102f80..e8c66dd 100644 --- a/src/components/FileExplorer.tsx +++ b/src/components/FileExplorer.tsx @@ -3,10 +3,10 @@ import { LoadingIndicator } from 'components/LoadingIndicator' import { Node } from 'components/Node' import { SearchBar } from 'components/SearchBar' import { useConfigs } from 'containers/ConfigsContext' +import { usePlatform } from 'containers/PlatformContext' import { connect } from 'driver/connect' import { FileExplorerCore } from 'driver/core' import { ConnectorState, Props } from 'driver/core/FileExplorer' -import { platform } from 'platforms' import * as React from 'react' import { useEvent, usePrevious } from 'react-use' import { FixedSizeList as List, ListChildComponentProps, ListProps } from 'react-window' @@ -193,6 +193,7 @@ function ListView({ } }, [listRef.current, focusedNode]) + const platform = usePlatform() const lastNodeLength = usePrevious(nodes.length) React.useEffect(() => { if (listRef.current && !focusedNode && lastNodeLength !== nodes.length) { diff --git a/src/components/Gitako.tsx b/src/components/Gitako.tsx index 7c68258..fa417c7 100644 --- a/src/components/Gitako.tsx +++ b/src/components/Gitako.tsx @@ -1,16 +1,19 @@ import { SideBar } from 'components/SideBar' import { ConfigsContext, ConfigsContextWrapper } from 'containers/ConfigsContext' +import { PlatformContextWrapper } from 'containers/PlatformContext' import * as React from 'react' import { ErrorBoundary } from './ErrorBoundary' export function Gitako() { return ( - - - {configContext => configContext && } - - + + + + {configContext => configContext && } + + + ) } diff --git a/src/components/Resizable.tsx b/src/components/Resizable.tsx index 2d6b594..8b5761c 100644 --- a/src/components/Resizable.tsx +++ b/src/components/Resizable.tsx @@ -1,6 +1,6 @@ import { HorizontalResizeHandler } from 'components/ResizeHandler' import { useConfigs } from 'containers/ConfigsContext' -import { platform } from 'platforms' +import { usePlatform } from 'containers/PlatformContext' import * as React from 'react' import { useWindowSize } from 'react-use' import { cx } from 'utils/cx' @@ -33,6 +33,7 @@ export function Resizable({ baseSize, className, children }: React.PropsWithChil configContext.set({ sideBarWidth: size }) }, [size]) + const platform = usePlatform() platform.useResizeStylesheets(size) const onResize = React.useCallback((size: number) => { diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index ba84776..c475100 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -3,9 +3,10 @@ import { FileExplorer } from 'components/FileExplorer' import { MetaBar } from 'components/MetaBar' import { Portal } from 'components/Portal' import { Resizable } from 'components/Resizable' -import { SettingsBar } from 'components/SettingsBar' +import { SettingsBar } from 'components/settings/SettingsBar' import { ToggleShowButton } from 'components/ToggleShowButton' import { useConfigs } from 'containers/ConfigsContext' +import { usePlatform } from 'containers/PlatformContext' import { connect } from 'driver/connect' import { SideBarCore } from 'driver/core' import { ConnectorState, Props } from 'driver/core/SideBar' @@ -22,6 +23,7 @@ import * as keyHelper from 'utils/keyHelper' const RawGitako: React.FC = function RawGitako(props) { const configContext = useConfigs() const accessToken = props.configContext.val.access_token + const platform = usePlatform() const intelligentToggle = configContext.val.intelligentToggle React.useEffect(() => { diff --git a/src/containers/PlatformContext.tsx b/src/containers/PlatformContext.tsx new file mode 100644 index 0000000..ce4729e --- /dev/null +++ b/src/containers/PlatformContext.tsx @@ -0,0 +1,17 @@ +import { resolvePlatformP } from 'platforms' +import { dummyPlatformForTypeSafety } from 'platforms/dummyPlatformForTypeSafety' +import * as React from 'react' + +const PlatformContext = React.createContext(dummyPlatformForTypeSafety) + +export function PlatformContextWrapper({ children }: React.PropsWithChildren<{}>) { + const [platform, setPlatform] = React.useState(dummyPlatformForTypeSafety) + React.useEffect(() => { + resolvePlatformP.then(setPlatform) + }, []) + return {children} +} + +export function usePlatform() { + return React.useContext(PlatformContext) +} diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index 4e26ae9..e482d6b 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -1,4 +1,4 @@ -import { platform } from 'platforms' +import { usePlatform } from 'containers/PlatformContext' import { GITHUB_OAUTH } from 'env' import * as React from 'react' import { useEvent } from 'react-use' @@ -164,6 +164,7 @@ export const GitHub: Platform = { } export function useGitHubAttachCopySnippetButton(copySnippetButton: boolean) { + const platform = usePlatform() const attachCopySnippetButton = React.useCallback( function attachCopySnippetButton() { if (platform !== GitHub) return @@ -176,6 +177,7 @@ export function useGitHubAttachCopySnippetButton(copySnippetButton: boolean) { } export function useGitHubAttachCopyFileButton(copyFileButton: boolean) { + const platform = usePlatform() const attachCopyFileButton = React.useCallback( function attachCopyFileButton() { if (platform !== GitHub) return diff --git a/src/platforms/Gitee/index.ts b/src/platforms/Gitee/index.ts index 89df05d..226fd13 100644 --- a/src/platforms/Gitee/index.ts +++ b/src/platforms/Gitee/index.ts @@ -1,4 +1,4 @@ -import { platform } from 'platforms' +import { usePlatform } from 'containers/PlatformContext' import { GITEE_OAUTH } from 'env' import * as React from 'react' import { useEvent } from 'react-use' @@ -164,6 +164,7 @@ export const Gitee: Platform = { } export function useGiteeAttachCopySnippetButton(copySnippetButton: boolean) { + const platform = usePlatform() const attachCopySnippetButton = React.useCallback( function attachCopySnippetButton() { if (platform !== Gitee) return From 4f5d0cc995c75e25f1151891f22c5b102f5de1c0 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 15:50:01 +0800 Subject: [PATCH 08/13] chore: minor adjustments --- src/content.scss | 3 +++ src/platforms/dummyPlatformForTypeSafety.ts | 13 ++++++------ src/platforms/index.ts | 22 +++++++++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/content.scss b/src/content.scss index 8427ea4..a805950 100644 --- a/src/content.scss +++ b/src/content.scss @@ -199,6 +199,9 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- .#{$name}-side-bar { @import '~@primer/css/base/index.scss'; + button { + border-radius: 6px; + } .#{$name}-position-wrapper { position: fixed; diff --git a/src/platforms/dummyPlatformForTypeSafety.ts b/src/platforms/dummyPlatformForTypeSafety.ts index 6d33ceb..57dd469 100644 --- a/src/platforms/dummyPlatformForTypeSafety.ts +++ b/src/platforms/dummyPlatformForTypeSafety.ts @@ -2,16 +2,17 @@ export const dummyPlatformForTypeSafety: Platform = { resolveMeta() { return null }, - getMetaData: callingDummyPlatformMethods, - getTreeData: callingDummyPlatformMethods, + getMetaData: dummyPlatformMethod, + getTreeData: dummyPlatformMethod, shouldShow() { return false }, - getCurrentPath: callingDummyPlatformMethods, - setOAuth: callingDummyPlatformMethods, - useResizeStylesheets: callingDummyPlatformMethods, + getCurrentPath: dummyPlatformMethod, + setOAuth: dummyPlatformMethod, + useResizeStylesheets: dummyPlatformMethod, + getOAuthLink: dummyPlatformMethod, } -function callingDummyPlatformMethods(): any { +function dummyPlatformMethod(): any { throw new Error(`Do not call dummy platform methods`) } diff --git a/src/platforms/index.ts b/src/platforms/index.ts index 8f22bbd..c3b712e 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -14,13 +14,14 @@ const platformsMap: Record< const CustomDomainsStorageKey = 'CUSTOM_DOMAINS' async function loadCustomDomains() { - const c = await storageHelper.get([CustomDomainsStorageKey]) - if (c) { - const { [CustomDomainsStorageKey]: customDomains } = c + const config = await storageHelper.get([CustomDomainsStorageKey]) + if (config) { + const { [CustomDomainsStorageKey]: customDomains } = config + type CustomDomains = Record // domain -> Platform if (customDomains) { - Object.keys(customDomains).forEach(domain => { - if (domain in platformsMap) { - platformsMap[domain as keyof typeof platformsMap].hosts.push(...customDomains[domain]) + Object.keys(customDomains as CustomDomains).forEach(domain => { + if (customDomains[domain] in platformsMap) { + platformsMap[customDomains[domain] as keyof typeof platformsMap].hosts.push(domain) } }) } @@ -42,6 +43,15 @@ let $platform: Platform = dummyPlatformForTypeSafety export const resolvePlatformP = resolvePlatform() resolvePlatformP.then(platform => ($platform = platform)) +export async function getPlatformName() { + await resolvePlatformP + const keys = Object.keys(platformsMap) as (keyof typeof platformsMap)[] + for (const key of keys) { + const { platform } = platformsMap[key] + if (platform === $platform) return key + } +} + export const platform: Platform = new Proxy(dummyPlatformForTypeSafety, { get(target, key: keyof Platform) { return $platform[key] From ea1123bfecb8d207cdc39dee5fab898adc68b349 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 15:50:21 +0800 Subject: [PATCH 09/13] feat: separate config between platforms --- src/utils/configHelper.ts | 33 +++++++++++++++++++++++++++++---- src/utils/storageHelper.ts | 2 +- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/utils/configHelper.ts b/src/utils/configHelper.ts index 95c0b04..421e50f 100644 --- a/src/utils/configHelper.ts +++ b/src/utils/configHelper.ts @@ -1,3 +1,4 @@ +import { getPlatformName } from 'platforms' import * as storageHelper from 'utils/storageHelper' export type Config = { @@ -22,7 +23,7 @@ export enum configKeys { icons = 'icons', } -export const defaultConfigs: Config = { +const defaultConfigs: Config = { sideBarWidth: 260, shortcut: undefined, access_token: undefined, @@ -43,11 +44,35 @@ function applyDefaultConfigs(configs: Partial) { }, {} as Config) } +type Storage = { + // save root level `configVersion` for easier future migrating + [key in 'configVersion' | string]: string + + // separate different platform configs to simplify interactions with browser storage API + // e.g. + // platform_GitHub?: Config +} + +async function migrateConfig() { + const config = await storageHelper.get(configKeyArray) + if (!config || !('configVersion' in config) || config.configVersion < '1.0.1') { + await storageHelper.set({ platform_GitHub: config, configVersion: '1.0.1' }) + } +} + +let platformName: string +const prepareConfig = new Promise(async resolve => { + await migrateConfig() + platformName = `platform_` + (await getPlatformName()) + resolve() +}) + export async function get(): Promise { - const config = await storageHelper.get(configKeyArray) - return applyDefaultConfigs(config || {}) + await prepareConfig + const config = await storageHelper.get>([platformName]) + return applyDefaultConfigs((config && config[platformName]) || {}) } export async function set(partialConfig: Partial) { - return await storageHelper.set(partialConfig) + return await storageHelper.set({ [platformName]: partialConfig }) } diff --git a/src/utils/storageHelper.ts b/src/utils/storageHelper.ts index 5125506..5357034 100644 --- a/src/utils/storageHelper.ts +++ b/src/utils/storageHelper.ts @@ -4,7 +4,7 @@ export async function get< T extends { [key: string]: any } ->(mapping: string[] | null): Promise { +>(mapping: string | string[] | null = null): Promise { try { return (await localStorage.get(mapping || undefined)) as T } catch (err) {} From ed3b117d30eb17e0f4b7f8702840524520cf73bc Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 15:58:02 +0800 Subject: [PATCH 10/13] refactor: reuse access denied description --- src/components/AccessDeniedDescription.tsx | 37 ++++++++++++++++++++ src/components/SideBar.tsx | 4 +-- src/platforms/GitHub/components.tsx | 39 ---------------------- 3 files changed, 39 insertions(+), 41 deletions(-) create mode 100644 src/components/AccessDeniedDescription.tsx delete mode 100644 src/platforms/GitHub/components.tsx diff --git a/src/components/AccessDeniedDescription.tsx b/src/components/AccessDeniedDescription.tsx new file mode 100644 index 0000000..4b334f1 --- /dev/null +++ b/src/components/AccessDeniedDescription.tsx @@ -0,0 +1,37 @@ +import { usePlatform } from 'containers/PlatformContext' +import { GITHUB_OAUTH } from 'env' +import { GitHub } from 'platforms/GitHub' +import * as React from 'react' + +export function AccessDeniedDescription({ hasToken }: { hasToken: boolean }) { + const platform = usePlatform() + return ( +
+
Access Denied
+ {hasToken ? ( + <> +

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

+ {platform === GitHub && ( +

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

+ )} + + ) : ( +

+ Gitako needs access token to read this project. Please setup access token in the settings + panel below. +

+ )} +
+ ) +} diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index c475100..7c2c374 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -1,4 +1,5 @@ import { raiseError } from 'analytics' +import { AccessDeniedDescription } from 'components/AccessDeniedDescription' import { FileExplorer } from 'components/FileExplorer' import { MetaBar } from 'components/MetaBar' import { Portal } from 'components/Portal' @@ -12,7 +13,6 @@ import { SideBarCore } from 'driver/core' import { ConnectorState, Props } from 'driver/core/SideBar' import { platform } from 'platforms' import { useGitHubAttachCopyFileButton, useGitHubAttachCopySnippetButton } from 'platforms/GitHub' -import { GitHubAccessDeniedError } from 'platforms/GitHub/components' import * as React from 'react' import { useEvent, useUpdateEffect } from 'react-use' import { cx } from 'utils/cx' @@ -144,7 +144,7 @@ RawGitako.defaultProps = { export const SideBar = connect(SideBarCore)(RawGitako) function AccessDeniedError({ hasToken }: { hasToken: boolean }) { - return + return } async function trySetUpAccessTokenWithCode() { diff --git a/src/platforms/GitHub/components.tsx b/src/platforms/GitHub/components.tsx deleted file mode 100644 index eef51ba..0000000 --- a/src/platforms/GitHub/components.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { GITHUB_OAUTH } from 'env' -import * as React from 'react' - -export function GitHubAccessDeniedError({ 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. -

- )} -
- ) -} From efedfc786f034db3d08d950f88832f6a89271c53 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 16:07:12 +0800 Subject: [PATCH 11/13] refactor: move copy file button --- src/{components => platforms/GitHub}/CopyFileButton.tsx | 2 +- src/platforms/GitHub/DOMHelper.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/{components => platforms/GitHub}/CopyFileButton.tsx (94%) diff --git a/src/components/CopyFileButton.tsx b/src/platforms/GitHub/CopyFileButton.tsx similarity index 94% rename from src/components/CopyFileButton.tsx rename to src/platforms/GitHub/CopyFileButton.tsx index 26040a8..ce124dd 100644 --- a/src/components/CopyFileButton.tsx +++ b/src/platforms/GitHub/CopyFileButton.tsx @@ -1,7 +1,7 @@ -import { getCodeElement } from 'platforms/GitHub/DOMHelper' import * as React from 'react' import { cx } from 'utils/cx' import { copyElementContent } from 'utils/DOMHelper' +import { getCodeElement } from './DOMHelper' type Props = {} diff --git a/src/platforms/GitHub/DOMHelper.ts b/src/platforms/GitHub/DOMHelper.ts index bab58bd..c53c4da 100644 --- a/src/platforms/GitHub/DOMHelper.ts +++ b/src/platforms/GitHub/DOMHelper.ts @@ -1,9 +1,9 @@ import { raiseError } from 'analytics' import { Clippy, ClippyClassName } from 'components/Clippy' -import { CopyFileButton, copyFileButtonClassName } from 'components/CopyFileButton' import * as React from 'react' import { $ } from 'utils/DOMHelper' import { renderReact } from 'utils/general' +import { CopyFileButton, copyFileButtonClassName } from './CopyFileButton' export function isInCodePage() { const branchListSelector = '#branch-select-menu.branch-select-menu' From b458e241fdb37949b66576902ae0d94c1b4e2ede Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 16:51:43 +0800 Subject: [PATCH 12/13] feat: show sidebar depends on meta data --- src/components/SideBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 7c2c374..c30ba5c 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -30,7 +30,7 @@ const RawGitako: React.FC = function RawGitako(props) { const shouldShow = intelligentToggle === null ? platform.shouldShow(props.metaData) : intelligentToggle props.setShouldShow(shouldShow) - }, [intelligentToggle]) + }, [intelligentToggle, props.metaData]) React.useEffect(() => { const { init } = props From eecae55235719cbbbc7024e5124795d2464f00fc Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 29 Mar 2020 16:52:04 +0800 Subject: [PATCH 13/13] chore: be more strict on config --- src/utils/configHelper.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/utils/configHelper.ts b/src/utils/configHelper.ts index 421e50f..0960848 100644 --- a/src/utils/configHelper.ts +++ b/src/utils/configHelper.ts @@ -54,7 +54,17 @@ type Storage = { } async function migrateConfig() { - const config = await storageHelper.get(configKeyArray) + // not referencing to enum above to prevent migrate future configs + const config = await storageHelper.get([ + 'sideBarWidth', + 'shortcut', + 'access_token', + 'compressSingletonFolder', + 'copyFileButton', + 'copySnippetButton', + 'intelligentToggle', + 'icons', + ]) if (!config || !('configVersion' in config) || config.configVersion < '1.0.1') { await storageHelper.set({ platform_GitHub: config, configVersion: '1.0.1' }) } @@ -73,6 +83,6 @@ export async function get(): Promise { return applyDefaultConfigs((config && config[platformName]) || {}) } -export async function set(partialConfig: Partial) { - return await storageHelper.set({ [platformName]: partialConfig }) +export async function set(config: Config) { + return await storageHelper.set({ [platformName]: config }) }