Merge branch 'feature/platforms' into develop

This commit is contained in:
EnixCoda 2020-03-29 16:55:37 +08:00
commit 1f92181337
No known key found for this signature in database
GPG key ID: 0C1A07377913A1DD
48 changed files with 1727 additions and 836 deletions

View file

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

@ -0,0 +1,4 @@
declare module '*.csv' {
const content: string
export default content
}

View 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>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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`,
},
]

View file

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

View file

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

View 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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View 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,
})
}

View file

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

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

View file

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

View 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
View file

@ -0,0 +1,117 @@
import { raiseError } from 'analytics'
import { GITEE_OAUTH } from 'env'
import { errors } from 'platforms'
function apiRateLimitExceeded(content: any /* safe any */) {
return content?.['documentation_url'] === 'https://developer.github.com/v3/#rate-limiting'
}
function isEmptyProject(content: any /* safe any */) {
return content?.['message'] === 'Git Repository is empty.'
}
function isBlockedProject(content: any /* safe any */) {
return content?.['message'] === 'Repository access blocked'
}
async function request(
url: string,
{
accessToken,
}: {
accessToken?: string
} = {},
) {
const headers = {} as HeadersInit & {
Authorization?: string
}
if (accessToken) {
headers.Authorization = `token ${accessToken}`
}
const res = await fetch(url, { headers })
const contentType = res.headers.get('Content-Type') || res.headers.get('content-type')
if (!contentType) {
throw new Error(`Response has no content type`)
} else if (!contentType.includes('application/json')) {
throw new Error(`Response content type is ${contentType}`)
}
// About res.ok:
// True if res.status between 200~299
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Response/ok
if (res.ok) {
return res.json()
} else {
debugger
if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND)
else if (res.status === 500) throw new Error(errors.SERVER_FAULT)
else {
const content = await res.json()
if (apiRateLimitExceeded(content)) throw new Error(errors.API_RATE_LIMIT)
if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT)
if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT)
// Unknown type of error, report it!
raiseError(new Error(res.statusText))
throw new Error(content && content.message)
}
}
}
export async function getRepoMeta(
userName: string,
repoName: string,
accessToken?: string,
): Promise<GiteeAPI.MetaData> {
const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent(
repoName,
)}`
return await request(url, { accessToken })
}
export async function getTreeData(
userName: string,
repoName: string,
branchName: string,
accessToken?: string,
): Promise<GiteeAPI.TreeData> {
const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent(
repoName,
)}/git/trees/${encodeURIComponent(branchName)}?recursive=1`
return await request(url, { accessToken })
}
export async function getBlobData(
userName: string,
repoName: string,
sha: string,
accessToken?: string,
): Promise<GiteeAPI.BlobData> {
const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(userName)}/${encodeURIComponent(
repoName,
)}/git/blobs/${encodeURIComponent(sha)}`
return await request(url, { accessToken })
}
export async function OAuth(code: string): Promise<GiteeAPI.OAuth> {
if (!GITEE_OAUTH.clientId || !GITEE_OAUTH.clientSecret)
throw new Error(`No Gitee OAuth credientials`)
const params = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: GITEE_OAUTH.clientId,
client_secret: GITEE_OAUTH.clientSecret,
})
const res = await fetch('https://gitee.com/oauth/token?' + params.toString(), {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
method: 'post',
})
return res.json()
}

View file

@ -0,0 +1,83 @@
import { raiseError } from 'analytics'
import { Clippy, ClippyClassName } from 'components/Clippy'
import * as React from 'react'
import { $ } from 'utils/DOMHelper'
import { renderReact } from 'utils/general'
export function isInRepoPage() {
const repoHeaderSelector = '.git-project-header-details'
return Boolean($(repoHeaderSelector))
}
export function isInCodePage() {
const branchListSelector = '#git-project-bread'
return Boolean($(branchListSelector))
}
export function getCurrentBranch() {
const selectedBranchButtonSelector = '#git-project-branch'
const branchButtonElement: HTMLElement = $(selectedBranchButtonSelector)
if (branchButtonElement) {
const branchNameSpanElement = branchButtonElement.querySelector('.text')
if (branchNameSpanElement) {
const partialBranchNameFromInnerText = branchNameSpanElement.textContent
if (!partialBranchNameFromInnerText?.includes('…')) return partialBranchNameFromInnerText
}
}
raiseError(new Error('cannot get current branch'))
}
const REPO_TYPE_PRIVATE = 'private'
const REPO_TYPE_PUBLIC = 'public'
export function getRepoPageType() {
const headerSelector = `.git-project-title .icon-lock`
return $(
headerSelector,
() => REPO_TYPE_PRIVATE,
() => REPO_TYPE_PUBLIC,
)
}
export function attachCopySnippet() {
const readmeSelector = '.file_content.markdown-body'
return $(readmeSelector, () => {
const readmeArticleSelector = '.file_content.markdown-body .highlight'
return $(readmeArticleSelector, readmeElement => {
const mouseOverCallback = async ({ target }: Event): Promise<void> => {
if (target instanceof Element && target.nodeName === 'PRE') {
if (
target.previousSibling === null ||
!(target.previousSibling instanceof Element) ||
!target.previousSibling.classList.contains(ClippyClassName)
) {
/**
* <article>
* <pre></pre> <!-- case A -->
* <div class="highlight">
* <pre></pre> <!-- case B -->
* </div>
* </article>
*/
if (target.parentNode) {
const clippyElement = await renderReact(
React.createElement(Clippy, { codeSnippetElement: target }),
)
if (clippyElement instanceof HTMLElement) {
target.parentNode.insertBefore(clippyElement, target)
}
}
}
}
}
readmeElement.addEventListener('mouseover', mouseOverCallback)
return () => {
readmeElement.removeEventListener('mouseover', mouseOverCallback)
const buttons = document.querySelectorAll(`.${ClippyClassName}`)
buttons.forEach(button => {
button.parentElement?.removeChild(button)
})
}
})
})
}

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

@ -0,0 +1,40 @@
declare namespace GiteeAPI {
type TreeItem = {
path: string
mode: string
sha: string
size: number
url: string
type: 'blob' | 'commit' | 'tree'
}
type TreeData = {
sha: string
truncated: boolean
tree: TreeItem[]
url: string
}
type MetaData = {
default_branch: string
html_url: string
parent: {
url: string
}
}
type BlobData = {
encoding: 'base64' | string
sha: string
content?: string
size: number
url: string
}
type OAuth = {
access_token?: string
scope?: string
token_type?: string
error_description?: string
}
}

View file

@ -0,0 +1,91 @@
import { raiseError } from 'analytics'
import { isInRepoPage } from './DOMHelper'
export function parse(): Partial<MetaData> & { path: string[] } {
const { pathname } = window.location
let [
,
// ignore content before the first '/'
userName,
repoName,
type,
...path // should be [...branchName.split('/'), ...filePath.split('/')]
] = unescape(decodeURIComponent(pathname)).split('/')
return {
userName,
repoName,
branchName: undefined,
type,
path,
}
}
export function parseSHA() {
const { type, path } = parse()
return type === 'blob' || type === 'tree' ? path[0] : undefined
}
// route types related to determining if sidebar should show
const TYPES = {
TREE: 'tree',
BLOB: 'blob',
COMMIT: 'commit',
// known but not related types: issues, pulls, wiki, insight,
// TODO: record more types
}
export function isInCodePage(metaData?: Partial<MetaData>) {
const mergedRepo = { ...parse(), ...metaData }
const { type, branchName } = mergedRepo
return Boolean(
isInRepoPage() &&
(!type || type === TYPES.TREE || type === TYPES.BLOB) &&
type !== TYPES.COMMIT &&
(branchName || (!type && !branchName)),
)
}
function isCommitPath(path: string[]) {
return isCompleteCommitSHA(path[0])
}
function isCompleteCommitSHA(sha?: string) {
return typeof sha === 'string' && /^[abcdef0-9]{40}$/i.test(sha)
}
export function getCurrentPath(branchName = '') {
const { path, type } = parse()
if (type === 'blob' || type === 'tree') {
if (isCommitPath(path)) {
// path = commit-SHA/path/to/item
path.shift()
} else {
// path = branch/name/path/to/item or HEAD/path/to/item
// HEAD is not a valid branch name. Getting HEAD means being detached.
if (path[0] === 'HEAD') path.shift()
else {
const splitBranchName = branchName.split('/')
while (splitBranchName.length) {
if (
splitBranchName[0] === path[0] ||
// Keep consuming as their heads are same
(splitBranchName.length === 1 && splitBranchName[0].startsWith(path[0]))
// This happens when visiting URLs like /blob/{commitSHA}/path/to/file
// and {commitSHA} is shorter than we got from DOM
) {
splitBranchName.shift()
path.shift()
} else {
raiseError(new Error(`branch name and path prefix not match`), {
branchName,
path: parse().path,
})
return []
}
}
}
}
return path.map(decodeURIComponent)
}
return []
}

View file

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

View file

@ -0,0 +1,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,
)
}

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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`)
}
}

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

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

View file

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

View file

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

View file

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