From 85736300d8c808f5da3f41a5282763bed2f84a8c Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Wed, 25 Mar 2020 00:56:59 +0800 Subject: [PATCH] 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), - } -}