mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
Merge branch 'feature/platforms' into develop
This commit is contained in:
commit
1f92181337
48 changed files with 1727 additions and 836 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "gitako",
|
||||
"version": "1.1.0",
|
||||
"description": "Awesome GitHub file tree.",
|
||||
"description": "File tree for GitHub, and more than that.",
|
||||
"repository": "https://github.com/EnixCoda/Gitako",
|
||||
"author": "EnixCoda",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
4
src/assets/icons/csv.d.ts
vendored
Normal file
4
src/assets/icons/csv.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.csv' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
37
src/components/AccessDeniedDescription.tsx
Normal file
37
src/components/AccessDeniedDescription.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={'description'}>
|
||||
<h5>Access Denied</h5>
|
||||
{hasToken ? (
|
||||
<>
|
||||
<p>
|
||||
Current access token is either invalid or not granted with permissions to access this
|
||||
project.
|
||||
</p>
|
||||
{platform === GitHub && (
|
||||
<p>
|
||||
You can grant or request access{' '}
|
||||
<a
|
||||
href={`https://github.com/settings/connections/applications/${GITHUB_OAUTH.clientId}`}
|
||||
>
|
||||
here
|
||||
</a>{' '}
|
||||
if you setup Gitako with OAuth.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Gitako needs access token to read this project. Please setup access token in the settings
|
||||
panel below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ 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'
|
||||
|
|
@ -11,8 +12,7 @@ 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<Props & ConnectorState> = 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
|
||||
|
|
@ -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) {
|
||||
|
|
@ -201,7 +202,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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ErrorBoundary>
|
||||
<ConfigsContextWrapper>
|
||||
<ConfigsContext.Consumer>
|
||||
{configContext => configContext && <SideBar configContext={configContext} />}
|
||||
</ConfigsContext.Consumer>
|
||||
</ConfigsContextWrapper>
|
||||
<PlatformContextWrapper>
|
||||
<ConfigsContextWrapper>
|
||||
<ConfigsContext.Consumer>
|
||||
{configContext => configContext && <SideBar configContext={configContext} />}
|
||||
</ConfigsContext.Consumer>
|
||||
</ConfigsContextWrapper>
|
||||
</PlatformContextWrapper>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={'meta-bar'}>
|
||||
<Breadcrumb>
|
||||
<Breadcrumb.Item href={userUrl}>{metaData.userName}</Breadcrumb.Item>
|
||||
<Breadcrumb.Item href={userUrl}>{userName}</Breadcrumb.Item>
|
||||
<Breadcrumb.Item className={'repo-name'} href={repoUrl}>
|
||||
{metaData.repoName}
|
||||
{repoName}
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Item selected>{metaData.branchName}</Breadcrumb.Item>
|
||||
<Breadcrumb.Item selected>{branchName}</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { HorizontalResizeHandler } from 'components/ResizeHandler'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { usePlatform } from 'containers/PlatformContext'
|
||||
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<Props>) {
|
||||
const [size, setSize] = React.useState(baseSize)
|
||||
|
|
@ -35,17 +33,8 @@ 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,
|
||||
)
|
||||
const platform = usePlatform()
|
||||
platform.useResizeStylesheets(size)
|
||||
|
||||
const onResize = React.useCallback((size: number) => {
|
||||
if (size < window.innerWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH) setSize(size)
|
||||
|
|
|
|||
|
|
@ -1,33 +1,43 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import { AccessDeniedDescription } from 'components/AccessDeniedDescription'
|
||||
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'
|
||||
import { oauth } from 'env'
|
||||
import { platform } from 'platforms'
|
||||
import { useGitHubAttachCopyFileButton, useGitHubAttachCopySnippetButton } from 'platforms/GitHub'
|
||||
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<Props & ConnectorState> = function RawGitako(props) {
|
||||
const configContext = useConfigs()
|
||||
const accessToken = props.configContext.val.access_token
|
||||
const platform = usePlatform()
|
||||
|
||||
const intelligentToggle = configContext.val.intelligentToggle
|
||||
React.useEffect(() => {
|
||||
const shouldShow =
|
||||
intelligentToggle === null ? platform.shouldShow(props.metaData) : intelligentToggle
|
||||
props.setShouldShow(shouldShow)
|
||||
}, [intelligentToggle, props.metaData])
|
||||
|
||||
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 +63,7 @@ const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
|
|||
function updateSideBarVisibility() {
|
||||
if (configContext.val.intelligentToggle === null) {
|
||||
props.setShouldShow(
|
||||
URLHelper.isInCodePage({
|
||||
platform.shouldShow({
|
||||
branchName: props.metaData?.branchName,
|
||||
}),
|
||||
)
|
||||
|
|
@ -63,33 +73,23 @@ const RawGitako: React.FC<Props & ConnectorState> = 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 +111,20 @@ const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
|
|||
<div className={'gitako-side-bar-body'}>
|
||||
<div className={'gitako-side-bar-content'}>
|
||||
{metaData && <MetaBar metaData={metaData} />}
|
||||
{errorDueToAuth
|
||||
? renderAccessDeniedError(Boolean(accessToken))
|
||||
: metaData && (
|
||||
<FileExplorer
|
||||
toggleShowSettings={toggleShowSettings}
|
||||
metaData={metaData}
|
||||
treeData={treeData}
|
||||
freeze={showSettings}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
)}
|
||||
{errorDueToAuth ? (
|
||||
<AccessDeniedError hasToken={Boolean(accessToken)} />
|
||||
) : (
|
||||
metaData && (
|
||||
<FileExplorer
|
||||
toggleShowSettings={toggleShowSettings}
|
||||
metaData={metaData}
|
||||
treeRoot={treeRoot}
|
||||
freeze={showSettings}
|
||||
accessToken={accessToken}
|
||||
loadWithPJAX={loadWithPJAX}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<SettingsBar toggleShowSettings={toggleShowSettings} activated={showSettings} />
|
||||
</div>
|
||||
|
|
@ -140,61 +143,15 @@ RawGitako.defaultProps = {
|
|||
|
||||
export const SideBar = connect(SideBarCore)(RawGitako)
|
||||
|
||||
function renderAccessDeniedError(hasToken: boolean) {
|
||||
return (
|
||||
<div className={'description'}>
|
||||
<h5>Access Denied</h5>
|
||||
{hasToken ? (
|
||||
<>
|
||||
<p>
|
||||
Current access token is either invalid or not granted with permissions to access this
|
||||
project.
|
||||
</p>
|
||||
<p>
|
||||
You can grant or request access{' '}
|
||||
<a href={`https://github.com/settings/connections/applications/${oauth.clientId}`}>
|
||||
here
|
||||
</a>{' '}
|
||||
if you setup Gitako with OAuth.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Gitako needs access token to read this project due to{' '}
|
||||
<a href="https://developer.github.com/v3/#rate-limiting" target="_blank">
|
||||
GitHub rate limiting
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="https://developer.github.com/v3/#authentication" target="_blank">
|
||||
auth needs
|
||||
</a>
|
||||
. Please setup access token in the settings panel below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
function AccessDeniedError({ hasToken }: { hasToken: boolean }) {
|
||||
return <AccessDeniedDescription hasToken={hasToken} />
|
||||
}
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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: <T>(value: T) => boolean
|
||||
onChange: (checked: boolean) => any
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
field: SimpleField
|
||||
|
||||
onChange?(): void
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { 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<Props>) {
|
|||
const useAccessToken = useStates('')
|
||||
const useAccessTokenHint = useStates<React.ReactNode>('')
|
||||
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<Props>) {
|
|||
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=${
|
||||
oauth.clientId
|
||||
}&scope=repo&redirect_uri=${encodeURIComponent(window.location.href)}`
|
||||
window.location.href = url
|
||||
window.location.href = platform.getOAuthLink()
|
||||
}}
|
||||
>
|
||||
Create with OAuth (recommended)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -24,7 +24,7 @@ const options: {
|
|||
{
|
||||
key: 'native',
|
||||
value: 'native',
|
||||
label: `Native GitHub icons`,
|
||||
label: `GitHub icons`,
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +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 { 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'
|
||||
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 = {
|
||||
|
|
@ -24,34 +25,27 @@ type Props = {
|
|||
toggleShowSettings: () => void
|
||||
}
|
||||
|
||||
export type SimpleField = {
|
||||
key: keyof Config
|
||||
label: string
|
||||
wikiLink?: string
|
||||
description?: string
|
||||
overwrite?: {
|
||||
value: <T>(value: T) => boolean
|
||||
onChange: (checked: boolean) => any
|
||||
}
|
||||
}
|
||||
|
||||
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<React.ReactNode>('')
|
||||
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 (
|
||||
<>
|
||||
<h2 className={'gitako-settings-bar-title'}>Settings</h2>
|
||||
|
|
@ -60,15 +54,17 @@ function SettingsBarContent() {
|
|||
<AccessTokenSettings />
|
||||
<SidebarSettings />
|
||||
<FileTreeSettings />
|
||||
<SettingsSection title={'More'}>
|
||||
{moreFields.map(field => (
|
||||
<React.Fragment key={field.key}>
|
||||
<SimpleToggleField field={field} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
{moreFields.length > 0 && (
|
||||
<SettingsSection title={'More'}>
|
||||
{moreFields.map(field => (
|
||||
<React.Fragment key={field.key}>
|
||||
<SimpleToggleField field={field} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{reloadHint && <div className={'hint'}>{reloadHint}</div>}
|
||||
</SettingsSection>
|
||||
{reloadHint && <div className={'hint'}>{reloadHint}</div>}
|
||||
</SettingsSection>
|
||||
)}
|
||||
<SettingsSection title={'Contact'}>
|
||||
<a href="https://github.com/EnixCoda/Gitako/issues" target="_blank">
|
||||
Report bug / Request feature.
|
||||
|
|
@ -4,6 +4,11 @@ import { Config } from 'utils/configHelper'
|
|||
|
||||
type Props = {}
|
||||
|
||||
type PartialValSet<T> = {
|
||||
val: T
|
||||
set: (val: Partial<T>) => void
|
||||
}
|
||||
|
||||
type ContextShape = PartialValSet<Config>
|
||||
export type ConfigsContextShape = ContextShape
|
||||
|
||||
|
|
|
|||
17
src/containers/PlatformContext.tsx
Normal file
17
src/containers/PlatformContext.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { resolvePlatformP } from 'platforms'
|
||||
import { dummyPlatformForTypeSafety } from 'platforms/dummyPlatformForTypeSafety'
|
||||
import * as React from 'react'
|
||||
|
||||
const PlatformContext = React.createContext<Platform>(dummyPlatformForTypeSafety)
|
||||
|
||||
export function PlatformContextWrapper({ children }: React.PropsWithChildren<{}>) {
|
||||
const [platform, setPlatform] = React.useState(dummyPlatformForTypeSafety)
|
||||
React.useEffect(() => {
|
||||
resolvePlatformP.then(setPlatform)
|
||||
}, [])
|
||||
return <PlatformContext.Provider value={platform}>{children}</PlatformContext.Provider>
|
||||
}
|
||||
|
||||
export function usePlatform() {
|
||||
return React.useContext(PlatformContext)
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -24,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;
|
||||
}
|
||||
|
|
@ -31,10 +30,32 @@ $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-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.with-gitako-spacing {
|
||||
// gitee
|
||||
&.git-project {
|
||||
width: auto; // shrink width
|
||||
.site-content {
|
||||
min-width: 1040px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $github-content-width) {
|
||||
body {
|
||||
// github
|
||||
body.env-production {
|
||||
min-width: $github-content-width;
|
||||
}
|
||||
}
|
||||
|
|
@ -177,6 +198,11 @@ $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;
|
||||
top: 0;
|
||||
|
|
@ -519,6 +545,21 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
|
|||
}
|
||||
}
|
||||
|
||||
// gitee
|
||||
.git-project {
|
||||
// reset styles
|
||||
.#{$name}-side-bar {
|
||||
input[type='text'],
|
||||
input[type='password'],
|
||||
.ui-autocomplete-input,
|
||||
textarea,
|
||||
.uneditable-input {
|
||||
padding: initial;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotateZ(0);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,35 @@
|
|||
import { withErrorLog } from 'analytics'
|
||||
import { Gitako } from 'components/Gitako'
|
||||
import { addMiddleware } from 'driver/connect'
|
||||
import { platform, resolvePlatformP } from 'platforms'
|
||||
import * as React from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import './content.scss'
|
||||
|
||||
addMiddleware(withErrorLog)
|
||||
resolvePlatformP.then(() => {
|
||||
if (platform.resolveMeta()) {
|
||||
addMiddleware(withErrorLog)
|
||||
|
||||
function init() {
|
||||
const SideBarElement = document.createElement('div')
|
||||
document.body.appendChild(SideBarElement)
|
||||
ReactDOM.render(<Gitako />, 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)
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init)
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
injectStyles(browser.extension.getURL('content.css'))
|
||||
|
||||
// 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)
|
||||
}
|
||||
const SideBarElement = document.createElement('div')
|
||||
document.body.appendChild(SideBarElement)
|
||||
ReactDOM.render(<Gitako />, SideBarElement)
|
||||
}
|
||||
|
||||
injectStyles(browser.extension.getURL('content.css'))
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init)
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof expandTo>
|
||||
}
|
||||
|
||||
type DepthMap = Map<TreeNode, number>
|
||||
|
||||
function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode, depths: DepthMap) {
|
||||
function getVisibleParentNode(
|
||||
nodes: TreeNode[],
|
||||
focusedNode: TreeNode,
|
||||
depths: Map<TreeNode, number>,
|
||||
) {
|
||||
const focusedNodeIndex = nodes.indexOf(focusedNode)
|
||||
const focusedNodeDepth = depths.get(focusedNode)
|
||||
let indexOfParentNode = focusedNodeIndex - 1
|
||||
|
|
@ -62,102 +60,13 @@ type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, Connecto
|
|||
export const init: BoundMethodCreator = dispatch => () =>
|
||||
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<Props, 'treeData' | 'metaData' | 'accessToken'> & Pick<Config, 'compressSingletonFolder'>,
|
||||
]> = dispatch => async ({ treeData, metaData, compressSingletonFolder, accessToken }) => {
|
||||
if (!treeData) return
|
||||
Pick<Props, 'treeRoot' | 'metaData' | 'accessToken'> & Pick<Config, 'compressSingletonFolder'>,
|
||||
]> = 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')
|
||||
|
|
|
|||
|
|
@ -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<void> | 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 })
|
||||
|
|
|
|||
12
src/env.ts
12
src/env.ts
|
|
@ -1,13 +1,13 @@
|
|||
export const IN_PRODUCTION_MODE = process.env.NODE_ENV === 'production'
|
||||
|
||||
type KnownPlatform = 'chrome' | 'firefox'
|
||||
type Platform = KnownPlatform | Exclude<string, keyof KnownPlatform>
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
export const GITEE_OAUTH = {
|
||||
clientId: process.env.GITEE_OAUTH_CLIENT_ID,
|
||||
clientSecret: process.env.GITEE_OAUTH_CLIENT_SECRET,
|
||||
}
|
||||
|
||||
export const VERSION = process.env.VERSION
|
||||
|
|
|
|||
27
src/global.d.ts
vendored
27
src/global.d.ts
vendored
|
|
@ -1,14 +1,19 @@
|
|||
type ValSet<T> = {
|
||||
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<T> = {
|
||||
val: T
|
||||
set: (val: Partial<T>) => 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
"128": "icons/Gitako-128.png",
|
||||
"256": "icons/Gitako-256.png"
|
||||
},
|
||||
"permissions": ["storage", "*://*.github.com/*", "*://*.sentry.io/*"],
|
||||
"permissions": ["storage", "<all_urls>"],
|
||||
"web_accessible_resources": ["icons/vscode/*", "content.css"],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://github.com/*"],
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["firefox-shim.js", "browser-polyfill.js", "content.js"]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
94
src/platforms/GitHub/API.ts
Normal file
94
src/platforms/GitHub/API.ts
Normal file
|
|
@ -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<GitHubAPI.MetaData> {
|
||||
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<GitHubAPI.TreeData> {
|
||||
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<GitHubAPI.BlobData> {
|
||||
const url = `https://api.github.com/repos/${userName}/${repoName}/git/blobs/${sha}`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
export async function OAuth(code: string): Promise<GitHubAPI.OAuth> {
|
||||
return await JSONRequest('https://github.com/login/oauth/access_token', {
|
||||
code,
|
||||
client_id: GITHUB_OAUTH.clientId,
|
||||
client_secret: GITHUB_OAUTH.clientSecret,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import * as React from 'react'
|
||||
import { cx } from 'utils/cx'
|
||||
import { copyElementContent, getCodeElement } from 'utils/DOMHelper'
|
||||
import { copyElementContent } from 'utils/DOMHelper'
|
||||
import { getCodeElement } from './DOMHelper'
|
||||
|
||||
type Props = {}
|
||||
|
||||
191
src/platforms/GitHub/DOMHelper.ts
Normal file
191
src/platforms/GitHub/DOMHelper.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import { Clippy, ClippyClassName } from 'components/Clippy'
|
||||
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'
|
||||
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<void> => {
|
||||
if (target instanceof Element && target.nodeName === 'PRE') {
|
||||
if (
|
||||
target.previousSibling === null ||
|
||||
!(target.previousSibling instanceof Element) ||
|
||||
!target.previousSibling.classList.contains(ClippyClassName)
|
||||
) {
|
||||
/**
|
||||
* <article>
|
||||
* <pre></pre> <!-- case A -->
|
||||
* <div class="highlight">
|
||||
* <pre></pre> <!-- case B -->
|
||||
* </div>
|
||||
* </article>
|
||||
*/
|
||||
if (target.parentNode) {
|
||||
const clippyElement = await renderReact(
|
||||
React.createElement(Clippy, { codeSnippetElement: target }),
|
||||
)
|
||||
if (clippyElement instanceof HTMLElement) {
|
||||
target.parentNode.insertBefore(clippyElement, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
readmeElement.addEventListener('mouseover', mouseOverCallback)
|
||||
return () => {
|
||||
readmeElement.removeEventListener('mouseover', mouseOverCallback)
|
||||
const buttons = document.querySelectorAll(`.${ClippyClassName}`)
|
||||
buttons.forEach(button => {
|
||||
button.parentElement?.removeChild(button)
|
||||
})
|
||||
}
|
||||
},
|
||||
() => {
|
||||
const plainReadmeSelector = '.repository-content div#readme .plain'
|
||||
$(plainReadmeSelector, undefined, () =>
|
||||
raiseError(
|
||||
new Error('cannot find mount point for copy snippet button while readme exists'),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
40
src/platforms/GitHub/Request.d.ts
vendored
Normal file
40
src/platforms/GitHub/Request.d.ts
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import { MetaData } from './GitHubHelper'
|
||||
|
||||
export function parse(): MetaData & { path: string[] } {
|
||||
export function parse(): Partial<MetaData> & { 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<MetaData>) {
|
||||
const mergedRepo = { ...parse(), ...metaData }
|
||||
const { type, branchName } = mergedRepo
|
||||
return Boolean(
|
||||
204
src/platforms/GitHub/index.ts
Normal file
204
src/platforms/GitHub/index.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { usePlatform } from 'containers/PlatformContext'
|
||||
import { GITHUB_OAUTH } from 'env'
|
||||
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'
|
||||
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<string, TreeNode>()
|
||||
const pathToItem = new Map<string, GitHubAPI.TreeItem>()
|
||||
|
||||
const root: TreeNode = { name: '', path: '', contents: [], type: 'tree' }
|
||||
pathToNode.set('', root)
|
||||
|
||||
tree.forEach(item => pathToItem.set(item.path, item))
|
||||
tree.forEach(item => {
|
||||
// bottom-up search for the deepest node created
|
||||
let path = item.path
|
||||
const itemsToCreateTreeNode: 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
|
||||
},
|
||||
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) {
|
||||
const platform = usePlatform()
|
||||
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 platform = usePlatform()
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
117
src/platforms/Gitee/API.ts
Normal file
117
src/platforms/Gitee/API.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import { GITEE_OAUTH } from 'env'
|
||||
import { errors } from 'platforms'
|
||||
|
||||
function apiRateLimitExceeded(content: any /* safe any */) {
|
||||
return content?.['documentation_url'] === 'https://developer.github.com/v3/#rate-limiting'
|
||||
}
|
||||
|
||||
function isEmptyProject(content: any /* safe any */) {
|
||||
return content?.['message'] === 'Git Repository is empty.'
|
||||
}
|
||||
|
||||
function isBlockedProject(content: any /* safe any */) {
|
||||
return content?.['message'] === 'Repository access blocked'
|
||||
}
|
||||
|
||||
async function request(
|
||||
url: string,
|
||||
{
|
||||
accessToken,
|
||||
}: {
|
||||
accessToken?: string
|
||||
} = {},
|
||||
) {
|
||||
const headers = {} as HeadersInit & {
|
||||
Authorization?: string
|
||||
}
|
||||
if (accessToken) {
|
||||
headers.Authorization = `token ${accessToken}`
|
||||
}
|
||||
const res = await fetch(url, { headers })
|
||||
const contentType = res.headers.get('Content-Type') || res.headers.get('content-type')
|
||||
if (!contentType) {
|
||||
throw new Error(`Response has no content type`)
|
||||
} else if (!contentType.includes('application/json')) {
|
||||
throw new Error(`Response content type is ${contentType}`)
|
||||
}
|
||||
// About res.ok:
|
||||
// True if res.status between 200~299
|
||||
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Response/ok
|
||||
if (res.ok) {
|
||||
return res.json()
|
||||
} else {
|
||||
debugger
|
||||
if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND)
|
||||
else if (res.status === 500) throw new Error(errors.SERVER_FAULT)
|
||||
else {
|
||||
const content = await res.json()
|
||||
if (apiRateLimitExceeded(content)) throw new Error(errors.API_RATE_LIMIT)
|
||||
if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT)
|
||||
if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT)
|
||||
// Unknown type of error, report it!
|
||||
raiseError(new Error(res.statusText))
|
||||
throw new Error(content && content.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRepoMeta(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
accessToken?: string,
|
||||
): Promise<GiteeAPI.MetaData> {
|
||||
const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent(
|
||||
repoName,
|
||||
)}`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
export async function getTreeData(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
branchName: string,
|
||||
accessToken?: string,
|
||||
): Promise<GiteeAPI.TreeData> {
|
||||
const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent(
|
||||
repoName,
|
||||
)}/git/trees/${encodeURIComponent(branchName)}?recursive=1`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
export async function getBlobData(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
sha: string,
|
||||
accessToken?: string,
|
||||
): Promise<GiteeAPI.BlobData> {
|
||||
const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent(
|
||||
repoName,
|
||||
)}/git/blobs/${encodeURIComponent(sha)}`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
export async function OAuth(code: string): Promise<GiteeAPI.OAuth> {
|
||||
if (!GITEE_OAUTH.clientId || !GITEE_OAUTH.clientSecret)
|
||||
throw new Error(`No Gitee OAuth credientials`)
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_id: GITEE_OAUTH.clientId,
|
||||
client_secret: GITEE_OAUTH.clientSecret,
|
||||
})
|
||||
|
||||
const res = await fetch('https://gitee.com/oauth/token?' + params.toString(), {
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
redirect: 'follow',
|
||||
referrerPolicy: 'no-referrer',
|
||||
method: 'post',
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
83
src/platforms/Gitee/DOMHelper.ts
Normal file
83
src/platforms/Gitee/DOMHelper.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import { Clippy, ClippyClassName } from 'components/Clippy'
|
||||
import * as React from 'react'
|
||||
import { $ } from 'utils/DOMHelper'
|
||||
import { renderReact } from 'utils/general'
|
||||
|
||||
export function isInRepoPage() {
|
||||
const repoHeaderSelector = '.git-project-header-details'
|
||||
return Boolean($(repoHeaderSelector))
|
||||
}
|
||||
|
||||
export function isInCodePage() {
|
||||
const branchListSelector = '#git-project-bread'
|
||||
return Boolean($(branchListSelector))
|
||||
}
|
||||
|
||||
export function getCurrentBranch() {
|
||||
const selectedBranchButtonSelector = '#git-project-branch'
|
||||
const branchButtonElement: HTMLElement = $(selectedBranchButtonSelector)
|
||||
if (branchButtonElement) {
|
||||
const branchNameSpanElement = branchButtonElement.querySelector('.text')
|
||||
if (branchNameSpanElement) {
|
||||
const partialBranchNameFromInnerText = branchNameSpanElement.textContent
|
||||
if (!partialBranchNameFromInnerText?.includes('…')) return partialBranchNameFromInnerText
|
||||
}
|
||||
}
|
||||
|
||||
raiseError(new Error('cannot get current branch'))
|
||||
}
|
||||
|
||||
const REPO_TYPE_PRIVATE = 'private'
|
||||
const REPO_TYPE_PUBLIC = 'public'
|
||||
export function getRepoPageType() {
|
||||
const headerSelector = `.git-project-title .icon-lock`
|
||||
return $(
|
||||
headerSelector,
|
||||
() => REPO_TYPE_PRIVATE,
|
||||
() => REPO_TYPE_PUBLIC,
|
||||
)
|
||||
}
|
||||
|
||||
export function attachCopySnippet() {
|
||||
const readmeSelector = '.file_content.markdown-body'
|
||||
return $(readmeSelector, () => {
|
||||
const readmeArticleSelector = '.file_content.markdown-body .highlight'
|
||||
return $(readmeArticleSelector, readmeElement => {
|
||||
const mouseOverCallback = async ({ target }: Event): Promise<void> => {
|
||||
if (target instanceof Element && target.nodeName === 'PRE') {
|
||||
if (
|
||||
target.previousSibling === null ||
|
||||
!(target.previousSibling instanceof Element) ||
|
||||
!target.previousSibling.classList.contains(ClippyClassName)
|
||||
) {
|
||||
/**
|
||||
* <article>
|
||||
* <pre></pre> <!-- case A -->
|
||||
* <div class="highlight">
|
||||
* <pre></pre> <!-- case B -->
|
||||
* </div>
|
||||
* </article>
|
||||
*/
|
||||
if (target.parentNode) {
|
||||
const clippyElement = await renderReact(
|
||||
React.createElement(Clippy, { codeSnippetElement: target }),
|
||||
)
|
||||
if (clippyElement instanceof HTMLElement) {
|
||||
target.parentNode.insertBefore(clippyElement, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
readmeElement.addEventListener('mouseover', mouseOverCallback)
|
||||
return () => {
|
||||
readmeElement.removeEventListener('mouseover', mouseOverCallback)
|
||||
const buttons = document.querySelectorAll(`.${ClippyClassName}`)
|
||||
buttons.forEach(button => {
|
||||
button.parentElement?.removeChild(button)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
40
src/platforms/Gitee/Request.d.ts
vendored
Normal file
40
src/platforms/Gitee/Request.d.ts
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
declare namespace GiteeAPI {
|
||||
type TreeItem = {
|
||||
path: string
|
||||
mode: string
|
||||
sha: string
|
||||
size: number
|
||||
url: string
|
||||
type: 'blob' | 'commit' | 'tree'
|
||||
}
|
||||
|
||||
type TreeData = {
|
||||
sha: string
|
||||
truncated: boolean
|
||||
tree: TreeItem[]
|
||||
url: string
|
||||
}
|
||||
|
||||
type MetaData = {
|
||||
default_branch: string
|
||||
html_url: string
|
||||
parent: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
type BlobData = {
|
||||
encoding: 'base64' | string
|
||||
sha: string
|
||||
content?: string
|
||||
size: number
|
||||
url: string
|
||||
}
|
||||
|
||||
type OAuth = {
|
||||
access_token?: string
|
||||
scope?: string
|
||||
token_type?: string
|
||||
error_description?: string
|
||||
}
|
||||
}
|
||||
91
src/platforms/Gitee/URLHelper.ts
Normal file
91
src/platforms/Gitee/URLHelper.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import { isInRepoPage } from './DOMHelper'
|
||||
|
||||
export function parse(): Partial<MetaData> & { path: string[] } {
|
||||
const { pathname } = window.location
|
||||
let [
|
||||
,
|
||||
// ignore content before the first '/'
|
||||
userName,
|
||||
repoName,
|
||||
type,
|
||||
...path // should be [...branchName.split('/'), ...filePath.split('/')]
|
||||
] = unescape(decodeURIComponent(pathname)).split('/')
|
||||
return {
|
||||
userName,
|
||||
repoName,
|
||||
branchName: undefined,
|
||||
type,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSHA() {
|
||||
const { type, path } = parse()
|
||||
return type === 'blob' || type === 'tree' ? path[0] : undefined
|
||||
}
|
||||
|
||||
// route types related to determining if sidebar should show
|
||||
const TYPES = {
|
||||
TREE: 'tree',
|
||||
BLOB: 'blob',
|
||||
COMMIT: 'commit',
|
||||
// known but not related types: issues, pulls, wiki, insight,
|
||||
// TODO: record more types
|
||||
}
|
||||
|
||||
export function isInCodePage(metaData?: Partial<MetaData>) {
|
||||
const mergedRepo = { ...parse(), ...metaData }
|
||||
const { type, branchName } = mergedRepo
|
||||
return Boolean(
|
||||
isInRepoPage() &&
|
||||
(!type || type === TYPES.TREE || type === TYPES.BLOB) &&
|
||||
type !== TYPES.COMMIT &&
|
||||
(branchName || (!type && !branchName)),
|
||||
)
|
||||
}
|
||||
|
||||
function isCommitPath(path: string[]) {
|
||||
return isCompleteCommitSHA(path[0])
|
||||
}
|
||||
|
||||
function isCompleteCommitSHA(sha?: string) {
|
||||
return typeof sha === 'string' && /^[abcdef0-9]{40}$/i.test(sha)
|
||||
}
|
||||
|
||||
export function getCurrentPath(branchName = '') {
|
||||
const { path, type } = parse()
|
||||
if (type === 'blob' || type === 'tree') {
|
||||
if (isCommitPath(path)) {
|
||||
// path = commit-SHA/path/to/item
|
||||
path.shift()
|
||||
} else {
|
||||
// path = branch/name/path/to/item or HEAD/path/to/item
|
||||
// HEAD is not a valid branch name. Getting HEAD means being detached.
|
||||
if (path[0] === 'HEAD') path.shift()
|
||||
else {
|
||||
const splitBranchName = branchName.split('/')
|
||||
while (splitBranchName.length) {
|
||||
if (
|
||||
splitBranchName[0] === path[0] ||
|
||||
// Keep consuming as their heads are same
|
||||
(splitBranchName.length === 1 && splitBranchName[0].startsWith(path[0]))
|
||||
// This happens when visiting URLs like /blob/{commitSHA}/path/to/file
|
||||
// and {commitSHA} is shorter than we got from DOM
|
||||
) {
|
||||
splitBranchName.shift()
|
||||
path.shift()
|
||||
} else {
|
||||
raiseError(new Error(`branch name and path prefix not match`), {
|
||||
branchName,
|
||||
path: parse().path,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return path.map(decodeURIComponent)
|
||||
}
|
||||
return []
|
||||
}
|
||||
39
src/platforms/Gitee/components.tsx
Normal file
39
src/platforms/Gitee/components.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { GITEE_OAUTH } from 'env'
|
||||
import * as React from 'react'
|
||||
|
||||
export function GiteeAccessDeniedError({ hasToken }: { hasToken: boolean }) {
|
||||
return (
|
||||
<div className={'description'}>
|
||||
<h5>Access Denied</h5>
|
||||
{hasToken ? (
|
||||
<>
|
||||
<p>
|
||||
Current access token is either invalid or not granted with permissions to access this
|
||||
project.
|
||||
</p>
|
||||
<p>
|
||||
You can grant or request access{' '}
|
||||
<a
|
||||
href={`https://github.com/settings/connections/applications/${GITEE_OAUTH.clientId}`}
|
||||
>
|
||||
here
|
||||
</a>{' '}
|
||||
if you setup Gitako with OAuth.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Gitako needs access token to read this project due to{' '}
|
||||
<a href="https://developer.github.com/v3/#rate-limiting" target="_blank">
|
||||
GitHub rate limiting
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="https://developer.github.com/v3/#authentication" target="_blank">
|
||||
auth needs
|
||||
</a>
|
||||
. Please setup access token in the settings panel below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
191
src/platforms/Gitee/index.ts
Normal file
191
src/platforms/Gitee/index.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { usePlatform } from 'containers/PlatformContext'
|
||||
import { GITEE_OAUTH } from 'env'
|
||||
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'
|
||||
import * as URLHelper from './URLHelper'
|
||||
|
||||
function parseTreeData(treeData: GiteeAPI.TreeData, metaData: MetaData) {
|
||||
const { tree } = treeData
|
||||
|
||||
// nodes are created from items and put onto tree
|
||||
const pathToNode = new Map<string, TreeNode>()
|
||||
const pathToItem = new Map<string, GiteeAPI.TreeItem>()
|
||||
|
||||
const root: TreeNode = { name: '', path: '', contents: [], type: 'tree' }
|
||||
pathToNode.set('', root)
|
||||
|
||||
tree.forEach(item => pathToItem.set(item.path, item))
|
||||
tree.forEach(item => {
|
||||
// bottom-up search for the deepest node created
|
||||
let path = item.path
|
||||
const itemsToCreateTreeNode: GiteeAPI.TreeItem[] = []
|
||||
while (path !== '' && !pathToNode.has(path)) {
|
||||
const item = pathToItem.get(path)
|
||||
if (item) {
|
||||
itemsToCreateTreeNode.push(item)
|
||||
}
|
||||
// 'a/b' -> 'a'
|
||||
// 'a' -> ''
|
||||
path = path.substring(0, path.lastIndexOf('/'))
|
||||
}
|
||||
|
||||
// top-down create nodes
|
||||
while (itemsToCreateTreeNode.length) {
|
||||
const item = itemsToCreateTreeNode.pop()
|
||||
if (!item) continue
|
||||
const node: TreeNode = {
|
||||
path: item.path || '',
|
||||
type: item.type || 'blob',
|
||||
name: item.path?.replace(/^.*\//, '') || '',
|
||||
url:
|
||||
item.url && item.type && item.path
|
||||
? getUrlForRedirect(
|
||||
metaData.userName,
|
||||
metaData.repoName,
|
||||
metaData.branchName,
|
||||
item.type,
|
||||
item.path,
|
||||
)
|
||||
: undefined,
|
||||
contents: item.type === 'tree' ? [] : undefined,
|
||||
}
|
||||
const parentNode = pathToNode.get(path)
|
||||
if (parentNode && parentNode.contents) {
|
||||
parentNode.contents.push(node)
|
||||
}
|
||||
pathToNode.set(node.path, node)
|
||||
path = node.path
|
||||
}
|
||||
})
|
||||
|
||||
sortFoldersToFront(root)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
function getUrlForRedirect(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
branchName: string,
|
||||
type = 'blob',
|
||||
path = '',
|
||||
) {
|
||||
return `https://gitee.com/${userName}/${repoName}/${type}/${branchName}/${path}`
|
||||
}
|
||||
|
||||
export const Gitee: Platform = {
|
||||
resolveMeta() {
|
||||
if (!DOMHelper.isInRepoPage()) {
|
||||
return null
|
||||
}
|
||||
|
||||
let detectedBranchName
|
||||
if (DOMHelper.isInCodePage()) {
|
||||
// not working well with non-branch blob
|
||||
// cannot handle '/' split branch name, should not use when possibly on branch page
|
||||
detectedBranchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA()
|
||||
}
|
||||
|
||||
const metaData = {
|
||||
...URLHelper.parse(),
|
||||
branchName: detectedBranchName || 'master',
|
||||
} as MetaData
|
||||
return metaData
|
||||
},
|
||||
async getMetaData(rawMetaData, accessToken) {
|
||||
const { userName, repoName, branchName } = rawMetaData
|
||||
const data = await API.getRepoMeta(userName, repoName, accessToken)
|
||||
const metaData: MetaData = {
|
||||
userName,
|
||||
repoName,
|
||||
branchName,
|
||||
userUrl: data?.html_url?.replace(/(.*)\/.*?$/, '$1'),
|
||||
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
|
||||
},
|
||||
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) {
|
||||
const platform = usePlatform()
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
18
src/platforms/dummyPlatformForTypeSafety.ts
Normal file
18
src/platforms/dummyPlatformForTypeSafety.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const dummyPlatformForTypeSafety: Platform = {
|
||||
resolveMeta() {
|
||||
return null
|
||||
},
|
||||
getMetaData: dummyPlatformMethod,
|
||||
getTreeData: dummyPlatformMethod,
|
||||
shouldShow() {
|
||||
return false
|
||||
},
|
||||
getCurrentPath: dummyPlatformMethod,
|
||||
setOAuth: dummyPlatformMethod,
|
||||
useResizeStylesheets: dummyPlatformMethod,
|
||||
getOAuthLink: dummyPlatformMethod,
|
||||
}
|
||||
|
||||
function dummyPlatformMethod(): any {
|
||||
throw new Error(`Do not call dummy platform methods`)
|
||||
}
|
||||
68
src/platforms/index.ts
Normal file
68
src/platforms/index.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import * as storageHelper from 'utils/storageHelper'
|
||||
import { dummyPlatformForTypeSafety } from './dummyPlatformForTypeSafety'
|
||||
import { Gitee } from './Gitee'
|
||||
import { GitHub } from './GitHub'
|
||||
|
||||
const platformsMap: Record<
|
||||
'GitHub' | 'GitLab' | 'Gitee',
|
||||
{ platform: Platform; hosts: string[] }
|
||||
> = {
|
||||
GitHub: { platform: GitHub, hosts: ['github.com'] },
|
||||
GitLab: { platform: GitHub, hosts: ['gitlab.com'] },
|
||||
Gitee: { platform: Gitee, hosts: ['gitee.com'] },
|
||||
}
|
||||
|
||||
const CustomDomainsStorageKey = 'CUSTOM_DOMAINS'
|
||||
async function loadCustomDomains() {
|
||||
const config = await storageHelper.get([CustomDomainsStorageKey])
|
||||
if (config) {
|
||||
const { [CustomDomainsStorageKey]: customDomains } = config
|
||||
type CustomDomains = Record<string, string> // domain -> Platform
|
||||
if (customDomains) {
|
||||
Object.keys(customDomains as CustomDomains).forEach(domain => {
|
||||
if (customDomains[domain] in platformsMap) {
|
||||
platformsMap[customDomains[domain] as keyof typeof platformsMap].hosts.push(domain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
loadCustomDomains()
|
||||
|
||||
async function resolvePlatform(): Promise<Platform> {
|
||||
for (const { hosts, platform } of Object.values(platformsMap)) {
|
||||
if (hosts.some(host => host === window.location.host)) {
|
||||
return platform
|
||||
}
|
||||
}
|
||||
return dummyPlatformForTypeSafety
|
||||
}
|
||||
|
||||
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<Platform>(dummyPlatformForTypeSafety, {
|
||||
get(target, key: keyof Platform) {
|
||||
return $platform[key]
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
}
|
||||
10
src/platforms/platform.d.ts
vendored
Normal file
10
src/platforms/platform.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
type Platform = {
|
||||
resolveMeta(): MetaData | null
|
||||
getMetaData(metaData: MetaData, accessToken?: string): Promise<MetaData>
|
||||
getTreeData(metaData: MetaData, accessToken?: string): Promise<TreeNode>
|
||||
shouldShow(metaData?: Partial<MetaData>): boolean
|
||||
getCurrentPath(branchName: string): string[] | null
|
||||
useResizeStylesheets(size: number): void
|
||||
setOAuth(code: string): Promise<string | null>
|
||||
getOAuthLink(): string
|
||||
}
|
||||
|
|
@ -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 $<EE extends Element, E extends (element: EE) => any, O extends () => any>(
|
||||
export function $<EE extends Element, E extends (element: EE) => any, O extends () => any>(
|
||||
selector: string,
|
||||
existCallback?: E,
|
||||
otherwise?: O,
|
||||
|
|
@ -53,49 +44,6 @@ function $<EE extends Element, E extends (element: EE) => 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<void> => {
|
||||
if (target instanceof Element && target.nodeName === 'PRE') {
|
||||
if (
|
||||
target.previousSibling === null ||
|
||||
!(target.previousSibling instanceof Element) ||
|
||||
!target.previousSibling.classList.contains(ClippyClassName)
|
||||
) {
|
||||
/**
|
||||
* <article>
|
||||
* <pre></pre> <!-- case A -->
|
||||
* <div class="highlight">
|
||||
* <pre></pre> <!-- case B -->
|
||||
* </div>
|
||||
* </article>
|
||||
*/
|
||||
if (target.parentNode) {
|
||||
const clippyElement = await renderReact(
|
||||
React.createElement(Clippy, { codeSnippetElement: target }),
|
||||
)
|
||||
if (clippyElement instanceof HTMLElement) {
|
||||
target.parentNode.insertBefore(clippyElement, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
readmeElement.addEventListener('mouseover', mouseOverCallback)
|
||||
return () => {
|
||||
readmeElement.removeEventListener('mouseover', mouseOverCallback)
|
||||
const buttons = document.querySelectorAll(`.${ClippyClassName}`)
|
||||
buttons.forEach(button => {
|
||||
button.parentElement?.removeChild(button)
|
||||
})
|
||||
}
|
||||
},
|
||||
() => {
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RepoMetaData> {
|
||||
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<TreeData> {
|
||||
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<MetaData, 'userName' | 'repoName' | 'accessToken'> & {
|
||||
sha: string
|
||||
}): Promise<BlobData> {
|
||||
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}`
|
||||
}
|
||||
|
|
@ -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<T>(arr: T[]) {
|
||||
return Array.from(new Set(arr))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -35,7 +36,7 @@ export const defaultConfigs: Config = {
|
|||
|
||||
const configKeyArray = Object.values(configKeys)
|
||||
|
||||
function applyDefaultConfigs(configs: Config) {
|
||||
function applyDefaultConfigs(configs: Partial<Config>) {
|
||||
return configKeyArray.reduce((applied, configKey) => {
|
||||
const key = configKey as keyof Config
|
||||
Object.assign(applied, { [key]: key in configs ? configs[key] : defaultConfigs[key] })
|
||||
|
|
@ -43,10 +44,45 @@ function applyDefaultConfigs(configs: Config) {
|
|||
}, {} as Config)
|
||||
}
|
||||
|
||||
export async function get(): Promise<Config> {
|
||||
return applyDefaultConfigs(await storageHelper.get(configKeyArray))
|
||||
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
|
||||
}
|
||||
|
||||
export async function set(partialConfig: Partial<Config>) {
|
||||
return await storageHelper.set(partialConfig)
|
||||
async function migrateConfig() {
|
||||
// not referencing to enum above to prevent migrate future configs
|
||||
const config = await storageHelper.get<Config | Storage>([
|
||||
'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' })
|
||||
}
|
||||
}
|
||||
|
||||
let platformName: string
|
||||
const prepareConfig = new Promise(async resolve => {
|
||||
await migrateConfig()
|
||||
platformName = `platform_` + (await getPlatformName())
|
||||
resolve()
|
||||
})
|
||||
|
||||
export async function get(): Promise<Config> {
|
||||
await prepareConfig
|
||||
const config = await storageHelper.get<Record<string, Config>>([platformName])
|
||||
return applyDefaultConfigs((config && config[platformName]) || {})
|
||||
}
|
||||
|
||||
export async function set(config: Config) {
|
||||
return await storageHelper.set({ [platformName]: config })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { ReactElement } from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import { TreeNode } from './VisibleNodesGenerator'
|
||||
|
||||
export function pick<T>(source: T, keys: string[]): Partial<T> {
|
||||
if (keys && typeof keys === 'object') {
|
||||
|
|
|
|||
78
src/utils/gitSubmodule.ts
Normal file
78
src/utils/gitSubmodule.ts
Normal file
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
40
src/utils/hooks/usePJAX.ts
Normal file
40
src/utils/hooks/usePJAX.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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',
|
||||
'#git-project-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
|
||||
}
|
||||
22
src/utils/hooks/useProgressBar.ts
Normal file
22
src/utils/hooks/useProgressBar.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<string, string>()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
const localStorage = browser.storage.local
|
||||
|
||||
export function get(mapping: string[] | null): Promise<any> | any {
|
||||
export async function get<
|
||||
T extends {
|
||||
[key: string]: any
|
||||
}
|
||||
>(mapping: string | string[] | null = null): Promise<T | void> {
|
||||
try {
|
||||
return localStorage.get(mapping || undefined)
|
||||
return (await localStorage.get(mapping || undefined)) as T
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = <T extends (...args: any[]) => any>(f: T) => (...args: Parameters<T>) => !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<string, TreeNode>()
|
||||
const pathToItem = new Map<string, RawItem>()
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue