Merge branch 'feature/advance' into develop

This commit is contained in:
EnixCoda 2019-11-16 15:11:00 +08:00
commit 6e18f3c999
No known key found for this signature in database
GPG key ID: 0C1A07377913A1DD
48 changed files with 1212 additions and 1266 deletions

View file

@ -5,13 +5,14 @@
{
"targets": {
"chrome": "67"
},
}
}
],
"@babel/preset-typescript",
"@babel/preset-react",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-object-rest-spread"
]

21
Makefile Executable file
View file

@ -0,0 +1,21 @@
build:
rm -rf dist
yarn build
upload-for-analytics:
VERSION=v$(node scripts/get-version.js)
# make sure sentry can retrieve current commit on remote
git push --tags
yarn sentry-cli releases new "$(VERSION)"
yarn sentry-cli releases set-commits "$(VERSION)" --auto
yarn sentry-cli releases files "$(VERSION)" upload-sourcemaps dist --no-rewrite
yarn sentry-cli releases finalize "$(VERSION)"
compress:
rm -f dist/gitako.zip
cd dist && zip -r gitako.zip * -x *.map
release:
$(MAKE) build
$(MAKE) upload-for-analytics
$(MAKE) compress

View file

@ -7,12 +7,12 @@
"license": "MIT",
"private": true,
"scripts": {
"start": "webpack --watch",
"start": "VERSION=dev-v$(node scripts/get-version.js) webpack --watch",
"debug-firefox": "web-ext run -s dist",
"analyse-bundle": "ANALYSE= NODE_ENV=production webpack",
"build": "NODE_ENV=production webpack",
"build": "VERSION=v$(node scripts/get-version.js) NODE_ENV=production webpack",
"postversion": "node scripts/version.js",
"roll": "./scripts/release.sh"
"roll": "make release"
},
"dependencies": {
"@primer/octicons": "^9.2.0",
@ -40,6 +40,7 @@
"@babel/core": "^7.3.4",
"@babel/plugin-proposal-class-properties": "^7.3.4",
"@babel/plugin-proposal-object-rest-spread": "^7.3.4",
"@babel/plugin-proposal-optional-chaining": "^7.6.0",
"@babel/preset-env": "^7.3.4",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.3.3",

View file

@ -1,18 +0,0 @@
#!/bin/sh
rm -rf dist
yarn build
GIT_SHA=$(git rev-parse HEAD)
VERSION=v$(node scripts/get-version.js)
echo "Got version $VERSION"
# sentry
git push --tags # make sure sentry can retrieve current commit on remote
yarn sentry-cli releases new "$VERSION"
yarn sentry-cli releases set-commits "$VERSION" --auto
yarn sentry-cli releases files "$VERSION" upload-sourcemaps dist --no-rewrite
yarn sentry-cli releases finalize "$VERSION"
cd dist
rm -f ./gitako.zip
zip -r gitako.zip * -x *.map

View file

@ -1,14 +1,13 @@
import * as Sentry from '@sentry/browser'
import { Middleware } from 'driver/connect.js'
import { IN_PRODUCTION_MODE } from 'env'
import { version } from '../package.json'
import { IN_PRODUCTION_MODE, VERSION } from 'env'
const PUBLIC_KEY = 'd22ec5c9cc874539a51c78388c12e3b0'
const PROJECT_ID = '1406497'
const sentryOptions: Sentry.BrowserOptions = {
dsn: `https://${PUBLIC_KEY}@sentry.io/${PROJECT_ID}`,
release: `v${version}`,
release: VERSION,
environment: IN_PRODUCTION_MODE ? 'production' : 'development',
// Not safe to activate all integrations in non-Chrome environments where Gitako may not run in top context
// https://docs.sentry.io/platforms/javascript/#sdk-integrations

36
src/components/Clippy.tsx Normal file
View file

@ -0,0 +1,36 @@
import * as React from 'react'
import { cx } from 'utils/cx'
import { copyElementContent } from 'utils/DOMHelper'
type Props = {
codeSnippetElement: Element
}
const className = 'clippy-wrapper'
export const ClippyClassName = className
export function Clippy({ codeSnippetElement }: Props) {
const [status, setStatus] = React.useState<'normal' | 'success' | 'fail'>('normal')
React.useEffect(() => {
const timer = window.setTimeout(() => {
setStatus('normal')
}, 1000)
return () => window.clearTimeout(timer)
}, [status])
const onClippyClick = React.useCallback(function onClippyClick() {
if (copyElementContent(codeSnippetElement)) {
setStatus('success')
} else {
setStatus('fail')
}
}, [])
return (
<div className={className}>
<button className="clippy" onClick={onClippyClick}>
<i className={cx('icon', status)} />
</button>
</div>
)
}

View file

@ -0,0 +1,42 @@
import * as React from 'react'
import { cx } from 'utils/cx'
import { copyElementContent, getCodeElement } from 'utils/DOMHelper'
type Props = {}
const className = 'gitako-copy-file-button'
export const copyFileButtonClassName = className
export function CopyFileButton(props: React.PropsWithChildren<Props>) {
const contents = {
success: 'Success!',
error: 'Copy failed!',
normal: 'Copy file',
}
const [content, setContent] = React.useState(contents.normal)
React.useEffect(() => {
if (content !== contents.normal) {
const timer = setTimeout(() => {
setContent(contents.normal)
}, 1000)
return () => clearTimeout(timer)
}
}, [content])
return (
<a
className={cx('btn btn-sm BtnGroup-item copy-file-btn', className)}
onClick={() => {
const codeElement = getCodeElement()
if (codeElement) {
if (copyElementContent(codeElement)) {
setContent(contents.success)
} else {
setContent(contents.error)
}
}
}}
>
{content}
</a>
)
}

View file

@ -0,0 +1,12 @@
import { raiseError } from 'analytics'
import * as React from 'react'
export class ErrorBoundary extends React.PureComponent {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
raiseError(error, errorInfo)
}
render() {
return this.props.children
}
}

View file

@ -1,145 +1,91 @@
import LoadingIndicator from 'components/LoadingIndicator'
import Node from 'components/Node'
import SearchBar from 'components/SearchBar'
import connect from 'driver/connect'
import { FileExplorer as FileExplorerCore } from 'driver/core'
import { ConnectorState } from 'driver/core/FileExplorer'
import { LoadingIndicator } from 'components/LoadingIndicator'
import { Node } from 'components/Node'
import { SearchBar } from 'components/SearchBar'
import { useConfigs } from 'containers/ConfigsContext'
import { connect } from 'driver/connect'
import { FileExplorerCore } from 'driver/core'
import { ConnectorState, Props } from 'driver/core/FileExplorer'
import * as React from 'react'
import { FixedSizeList as List, ListChildComponentProps } from 'react-window'
import cx from 'utils/cx'
import { MetaData, TreeData } from 'utils/GitHubHelper'
import { FixedSizeList as List, ListChildComponentProps, ListProps } from 'react-window'
import { cx } from 'utils/cx'
import { usePrevious } from 'utils/hooks'
import { TreeNode, VisibleNodes } from 'utils/VisibleNodesGenerator'
import Icon from './Icon'
import SizeObserver from './SizeObserver'
import { Icon } from './Icon'
import { SizeObserver } from './SizeObserver'
export type Props = {
treeData?: TreeData
metaData: MetaData
freeze: boolean
compressSingletonFolder: boolean
accessToken: string | undefined
toggleShowSettings: React.MouseEventHandler
}
const VisibleNodesContext = React.createContext<VisibleNodes | null>(null)
class FileExplorer extends React.Component<Props & ConnectorState> {
static defaultProps: Partial<Props & ConnectorState> = {
freeze: false,
searchKey: '',
visibleNodes: null,
}
const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplorer(props) {
const { visibleNodes, freeze, onNodeClick, searchKey } = props
const {
val: { access_token: accessToken, compressSingletonFolder },
} = useConfigs()
componentWillMount() {
const { init, setUpTree, treeData, metaData, compressSingletonFolder, accessToken } = this.props
React.useEffect(() => {
const { init } = props
init()
}, [])
React.useEffect(() => {
const { setUpTree, treeData, metaData } = props
setUpTree({ treeData, metaData, compressSingletonFolder, accessToken })
}
}, [props.setUpTree, props.treeData, compressSingletonFolder, accessToken])
componentDidMount() {
const { execAfterRender } = this.props
React.useEffect(() => {
const { execAfterRender } = props
execAfterRender()
}
componentWillReceiveProps(nextProps: Props & ConnectorState) {
if (nextProps.treeData !== this.props.treeData) {
const { setUpTree, treeData, metaData, compressSingletonFolder, accessToken } = nextProps
setUpTree({ treeData, metaData, compressSingletonFolder, accessToken })
}
}
componentDidUpdate() {
const { execAfterRender } = this.props
execAfterRender()
}
renderFiles(visibleNodes: VisibleNodes) {
const { nodes, focusedNode } = visibleNodes
const { searchKey } = this.props
const inSearch = searchKey !== ''
if (inSearch && nodes.length === 0) {
return <label className={'no-results'}>No results found.</label>
}
return (
<SizeObserver className={'files'}>
{({ width = 0, height = 0 }) => (
<this.ListV focusedNode={focusedNode} nodes={nodes} height={height} width={width} />
)}
</SizeObserver>
)
}
ListV = React.memo<{
nodes: TreeNode[]
height: number
width: number
focusedNode: TreeNode | null
}>(({ nodes, width, height, focusedNode }) => {
const listRef = React.useRef<List>(null)
React.useEffect(() => {
if (focusedNode && listRef.current) {
listRef.current.scrollToItem(nodes.indexOf(focusedNode), 'smart')
}
}, [listRef.current, focusedNode])
const lastNodeLength = usePrevious(nodes.length)
React.useEffect(() => {
if (listRef.current && !focusedNode && lastNodeLength !== nodes.length) {
listRef.current.scrollTo(0)
}
}, [listRef.current, focusedNode, nodes.length])
return (
<List
ref={listRef}
itemKey={(index, { nodes }) => {
const node = nodes[index]
return node && node.path
}}
itemData={{ nodes }}
itemCount={nodes.length}
itemSize={35}
height={height}
width={width}
>
{this.VirtualNode}
</List>
)
})
VirtualNode = React.memo<ListChildComponentProps>(({ index, style }) => {
const { visibleNodes, onNodeClick } = this.props
if (!visibleNodes) return null
const { nodes, depths, focusedNode, expandedNodes } = visibleNodes
const node = nodes[index]
return (
<Node
style={style}
key={node.path}
node={node}
depth={depths.get(node) || 0}
focused={focusedNode === node}
expanded={expandedNodes.has(node)}
onClick={onNodeClick}
renderActions={this.renderActions}
/>
)
})
private renderActions: Node['props']['renderActions'] = node => {
const { searchKey, goTo } = this.props
return (
searchKey && (
const renderActions: React.ComponentProps<typeof Node>['renderActions'] = React.useCallback(
node =>
searchKey ? (
<button
title={'Reveal in file tree'}
className={'go-to-button'}
onClick={this.revealNode(goTo, node)}
onClick={revealNode(props.goTo, node)}
>
<Icon type="go-to" />
</button>
)
)
}
) : null,
[searchKey, props.goTo],
)
revealNode(
const renderNode = React.useCallback(
({ index, style }: ListChildComponentProps) => (
<VirtualNode
index={index}
style={style}
onNodeClick={onNodeClick}
renderActions={renderActions}
/>
),
[renderActions, onNodeClick],
)
const renderFiles = React.useCallback(
({ nodes, focusedNode }: VisibleNodes) => {
const inSearch = searchKey !== ''
if (inSearch && nodes.length === 0) {
return <label className={'no-results'}>No results found.</label>
}
return (
<SizeObserver className={'files'}>
{({ width = 0, height = 0 }) => (
<ListView
renderNode={renderNode}
focusedNode={focusedNode}
nodes={nodes}
height={height}
width={width}
/>
)}
</SizeObserver>
)
},
[searchKey, ListView, renderNode],
)
const revealNode = React.useCallback(function revealNode(
goTo: (path: string[]) => void,
node: TreeNode,
): (event: React.MouseEvent<HTMLElement, MouseEvent>) => void {
@ -148,39 +94,113 @@ class FileExplorer extends React.Component<Props & ConnectorState> {
e.preventDefault()
goTo(node.path.split('/'))
}
}
},
[])
render() {
const {
stateText,
visibleNodes,
freeze,
handleKeyDown,
search,
toggleShowSettings,
onFocusSearchBar,
searchKey,
} = this.props
return (
return (
<VisibleNodesContext.Provider value={visibleNodes}>
<div
className={cx(`file-explorer`, { freeze })}
tabIndex={-1}
onKeyDown={handleKeyDown}
onClick={freeze ? toggleShowSettings : undefined}
onKeyDown={props.handleKeyDown}
onClick={freeze ? props.toggleShowSettings : undefined}
>
{stateText ? (
<LoadingIndicator text={stateText} />
{props.stateText ? (
<LoadingIndicator text={props.stateText} />
) : (
visibleNodes && (
<React.Fragment>
<SearchBar searchKey={searchKey} onSearch={search} onFocus={onFocusSearchBar} />
{this.renderFiles(visibleNodes)}
</React.Fragment>
<>
<SearchBar
searchKey={searchKey}
onSearch={props.search}
onFocus={props.onFocusSearchBar}
/>
{renderFiles(visibleNodes)}
</>
)
)}
</div>
)
}
</VisibleNodesContext.Provider>
)
}
export default connect<Props, ConnectorState>(FileExplorerCore)(FileExplorer)
RawFileExplorer.defaultProps = {
freeze: false,
searchKey: '',
visibleNodes: null,
}
export const FileExplorer = connect(FileExplorerCore)(RawFileExplorer)
function VirtualNode({
index,
style,
onNodeClick,
renderActions,
}: {
index: number
style: React.CSSProperties
onNodeClick: (treeNode: TreeNode) => void
renderActions: ((node: TreeNode) => React.ReactNode) | undefined
}) {
const visibleNodes = React.useContext(VisibleNodesContext)
if (!visibleNodes) return null
const { nodes, depths, focusedNode, expandedNodes } = visibleNodes
const node = nodes[index]
return (
<Node
style={style}
key={node.path}
node={node}
depth={depths.get(node) || 0}
focused={focusedNode === node}
expanded={expandedNodes.has(node)}
onClick={onNodeClick}
renderActions={renderActions}
/>
)
}
function ListView({
nodes,
width,
height,
focusedNode,
renderNode,
}: {
nodes: TreeNode[]
height: number
width: number
focusedNode: TreeNode | null
renderNode: ListProps['children']
}) {
const listRef = React.useRef<List>(null)
React.useEffect(() => {
if (focusedNode && listRef.current) {
listRef.current.scrollToItem(nodes.indexOf(focusedNode), 'smart')
}
}, [listRef.current, focusedNode])
const lastNodeLength = usePrevious(nodes.length)
React.useEffect(() => {
if (listRef.current && !focusedNode && lastNodeLength !== nodes.length) {
listRef.current.scrollTo(0)
}
}, [listRef.current, focusedNode, nodes.length])
return (
<List
ref={listRef}
itemKey={(index, { nodes }) => {
const node = nodes[index]
return node && node.path
}}
itemData={{ nodes }}
itemCount={nodes.length}
itemSize={35}
height={height}
width={width}
>
{renderNode}
</List>
)
}

View file

@ -1,13 +1,16 @@
import { raiseError } from 'analytics'
import SideBar from 'components/SideBar'
import { SideBar } from 'components/SideBar'
import { ConfigsContext, ConfigsContextWrapper } from 'containers/ConfigsContext'
import * as React from 'react'
import { ErrorBoundary } from './ErrorBoundary'
export default class Gitako extends React.PureComponent {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
raiseError(error, errorInfo)
}
render() {
return <SideBar />
}
export function Gitako() {
return (
<ErrorBoundary>
<ConfigsContextWrapper>
<ConfigsContext.Consumer>
{configContext => configContext && <SideBar configContext={configContext} />}
</ConfigsContext.Consumer>
</ConfigsContextWrapper>
</ErrorBoundary>
)
}

View file

@ -16,7 +16,7 @@ import Octicon, {
X,
} from '@primer/octicons-react'
import * as React from 'react'
import cx from 'utils/cx'
import { cx } from 'utils/cx'
function getSVGIconComponent(
type: string,
@ -123,7 +123,7 @@ type Props = {
onClick?: (event: React.MouseEvent<HTMLElement>) => void
}
const Icon: React.SFC<Props> = function Icon({ type, className = undefined, ...otherProps }) {
export function Icon({ type, className = undefined, ...otherProps }: Props) {
const { name, IconComponent } = getSVGIconComponent(type)
const mergedClassName = cx('octicon', name)
return (
@ -135,5 +135,3 @@ const Icon: React.SFC<Props> = function Icon({ type, className = undefined, ...o
</div>
)
}
export default Icon

View file

@ -1,10 +1,10 @@
import Icon from 'components/Icon'
import { Icon } from 'components/Icon'
import * as React from 'react'
type Props = {
text: React.ReactNode
}
export default function LoadingIndicator({ text }: Props) {
export function LoadingIndicator({ text }: Props) {
return (
<div className={'loading-indicator-container'}>
<div className={'loading-indicator'}>

View file

@ -1,12 +1,12 @@
import * as React from 'react'
import { MetaData } from 'utils/GitHubHelper'
import { safeTouch } from 'safe-touch'
import { MetaData } from 'utils/GitHubHelper'
type Props = {
metaData: MetaData
}
export default function MetaBar({ metaData }: Props) {
export function MetaBar({ metaData }: Props) {
const userUrl = safeTouch(metaData).api.owner.html_url()
const repoUrl = safeTouch(metaData).api.html_url()
return (

View file

@ -0,0 +1,52 @@
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { Config } from 'utils/configHelper'
export type SimpleField = {
key: keyof Config
label: string
wikiLink?: string
description?: string
overwrite?: Props['overwrite']
}
type Props = {
field: SimpleField
onChange?(): void
overwrite?: {
value: <T>(value: T) => boolean
onChange: (checked: boolean) => any
}
}
export function SimpleFieldInput({ field, overwrite, onChange }: Props) {
const configContext = useConfigs()
const value = configContext.val[field.key]
return (
<label htmlFor={field.key}>
<input
id={field.key}
name={field.key}
type={'checkbox'}
onChange={async e => {
const enabled = e.currentTarget.checked
configContext.set({ [field.key]: overwrite ? overwrite.onChange(enabled) : enabled })
if (onChange) onChange()
}}
checked={overwrite ? overwrite.value(value) : Boolean(value)}
/>
&nbsp;{field.label}&nbsp;
{field.wikiLink ? (
<a href={field.wikiLink} target={'_blank'}>
(?)
</a>
) : (
field.description && (
<span className={'description'} title={field.description}>
(?)
</span>
)
)}
</label>
)
}

View file

@ -1,6 +1,6 @@
import Icon from 'components/Icon'
import { Icon } from 'components/Icon'
import * as React from 'react'
import cx from 'utils/cx'
import { cx } from 'utils/cx'
import { OperatingSystems, os } from 'utils/general'
import { TreeNode } from 'utils/VisibleNodesGenerator'
@ -24,42 +24,42 @@ type Props = {
renderActions?(node: TreeNode): React.ReactNode
style?: React.CSSProperties
}
export default class Node extends React.PureComponent<Props> {
onClick: React.MouseEventHandler = event => {
if (
(os === OperatingSystems.macOS && event.metaKey) ||
(os === OperatingSystems.Windows && event.ctrlKey)
) {
// Open in new tab
return
}
event.preventDefault()
const { node, onClick } = this.props
onClick(node)
}
export function Node({ node, depth, expanded, focused, renderActions, style, onClick }: Props) {
const onClickNode: React.MouseEventHandler = React.useCallback(
event => {
if (
(os === OperatingSystems.macOS && event.metaKey) ||
(os === OperatingSystems.Windows && event.ctrlKey)
) {
// The default behavior, open in new tab
return
}
event.preventDefault()
render() {
const { node, depth, expanded, focused, renderActions, style } = this.props
const { name, path } = node
return (
<div
className={cx(`node-item-row`, { focused, disabled: node.accessDenied })}
style={style}
title={path}
>
<a href={node.url} onClick={this.onClick}>
<div
className={cx('node-item', { expanded })}
style={{ paddingLeft: `${10 + 20 * depth}px` }}
>
<div className={'node-item-label'}>
<Icon type={getIconType(node)} />
<span className={'node-item-name'}>{name}</span>
</div>
{renderActions && <div>{renderActions(node)}</div>}
onClick(node)
},
[node, onClick],
)
const { name, path } = node
return (
<div
className={cx(`node-item-row`, { focused, disabled: node.accessDenied })}
style={style}
title={path}
>
<a href={node.url} onClick={onClickNode}>
<div
className={cx('node-item', { expanded })}
style={{ paddingLeft: `${10 + 20 * depth}px` }}
>
<div className={'node-item-label'}>
<Icon type={getIconType(node)} />
<span className={'node-item-name'}>{name}</span>
</div>
</a>
</div>
)
}
{renderActions && <div>{renderActions(node)}</div>}
</div>
</a>
</div>
)
}

View file

@ -1,12 +1,12 @@
import * as React from 'react'
import DOMHelper from 'utils/DOMHelper'
import * as DOMHelper from 'utils/DOMHelper'
type Props<P> = {
to: string
children: React.ReactElement<P>
}
export default function PJAXLink<P>({ to, children }: Props<P>) {
export function PJAXLink<P>({ to, children }: Props<P>) {
return React.cloneElement(children, {
...children.props,
onClick: () => DOMHelper.loadWithPJAX(to),

View file

@ -5,12 +5,8 @@ type Props = {
into: Element | null
}
class Portal extends React.PureComponent<Props> {
render() {
const { into, children } = this.props
if (!(into instanceof Element)) return null
return ReactDOM.createPortal(children, into)
}
export function Portal(props: React.PropsWithChildren<Props>) {
const { into, children } = props
if (!(into instanceof Element)) return null
return ReactDOM.createPortal(children, into)
}
export default Portal

View file

@ -1,10 +1,10 @@
import { HorizontalResizeHandler } from 'components/ResizeHandler'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import HorizontalResizeHandler from 'components/ResizeHandler'
import cx from 'utils/cx'
import { useWindowSize, useMediaStyleSheet } from 'utils/hooks'
import { cx } from 'utils/cx'
import { bodySpacingClassName } from 'utils/DOMHelper'
import configHelper, { configKeys } from 'utils/configHelper'
import * as features from 'utils/features'
import { useMediaStyleSheet, useWindowSize } from 'utils/hooks'
export type Size = number
type Props = {
@ -15,12 +15,9 @@ type Props = {
const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100
const GITHUB_WIDTH = 1020
export default function Resizable({
baseSize,
className,
children,
}: React.PropsWithChildren<Props>) {
export function Resizable({ baseSize, className, children }: React.PropsWithChildren<Props>) {
const [size, setSize] = React.useState(baseSize)
const configContext = useConfigs()
React.useEffect(() => {
setSize(baseSize)
@ -36,7 +33,7 @@ export default function Resizable({
React.useEffect(() => {
document.documentElement.style.setProperty('--gitako-width', size + 'px')
configHelper.setOne(configKeys.sideBarWidth, size)
configContext.set({ sideBarWidth: size })
}, [size])
useMediaStyleSheet(

View file

@ -1,4 +1,4 @@
import Icon from 'components/Icon'
import { Icon } from 'components/Icon'
import * as React from 'react'
import { Size } from './Resizable'
@ -8,51 +8,46 @@ type Props = {
style?: React.CSSProperties
}
export default class HorizontalResizeHandler extends React.PureComponent<Props> {
pointerDown = false
startX = 0
baseSize = this.props.size
export function HorizontalResizeHandler({ onResize, size, style }: Props) {
const pointerDown = React.useRef(false)
const startX = React.useRef(0)
const baseSize = React.useRef(size)
const latestPropSize = React.useRef(size)
componentWillReceiveProps(nextProps: Props) {
if (!this.pointerDown) {
// update baseSize when not resizing
this.baseSize = nextProps.size
React.useEffect(() => {
latestPropSize.current = size
}, [size])
const onPointerDown = React.useCallback(({ clientX }: React.MouseEvent) => {
startX.current = clientX
pointerDown.current = true
baseSize.current = latestPropSize.current
}, [])
React.useEffect(() => {
const onPointerMove = ({ clientX }: MouseEvent) => {
if (!pointerDown.current) return
const shift = clientX - startX.current
onResize(baseSize.current + shift)
}
}
window.addEventListener('mousemove', onPointerMove)
return () => window.removeEventListener('mousemove', onPointerMove)
}, [onResize])
subscribeEvents = () => {
window.addEventListener('mousemove', this.onPointerMove)
window.addEventListener('mouseup', this.onPointerUp)
}
React.useEffect(() => {
const onPointerUp = () => {
if (pointerDown.current) {
pointerDown.current = false
baseSize.current = latestPropSize.current
}
}
window.addEventListener('mouseup', onPointerUp)
return () => window.removeEventListener('mouseup', onPointerUp)
}, [])
unsubscribeEvents = () => {
window.removeEventListener('mousemove', this.onPointerMove)
window.removeEventListener('mouseup', this.onPointerUp)
}
onPointerDown = ({ clientX }: React.MouseEvent) => {
this.startX = clientX
this.pointerDown = true
this.subscribeEvents()
}
onPointerMove = ({ clientX }: MouseEvent) => {
if (!this.pointerDown) return
this.props.onResize(clientX - this.startX + this.baseSize)
}
onPointerUp = () => {
this.pointerDown = false
this.baseSize = this.props.size
this.unsubscribeEvents()
}
render() {
const { style } = this.props
return (
<div className={'gitako-resize-handler'} onMouseDown={this.onPointerDown} style={style}>
<Icon type={'grabber'} className={'grabber-icon'} />
</div>
)
}
return (
<div className={'gitako-resize-handler'} onMouseDown={onPointerDown} style={style}>
<Icon type={'grabber'} className={'grabber-icon'} />
</div>
)
}

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import cx from 'utils/cx'
import { cx } from 'utils/cx'
type Props = {
onSearch: (searchKey: string) => void
@ -7,7 +7,7 @@ type Props = {
searchKey: string
}
export default function SearchBar({ onSearch, onFocus, searchKey }: Props) {
export function SearchBar({ onSearch, onFocus, searchKey }: Props) {
return (
<div className={'search-input-wrapper'}>
<input

View file

@ -1,11 +1,11 @@
import { raiseError } from 'analytics'
import Icon from 'components/Icon'
import { oauth } from 'env'
import { Icon } from 'components/Icon'
import { useConfigs } from 'containers/ConfigsContext'
import { oauth, VERSION } from 'env'
import * as React from 'react'
import configHelper, { Config, configKeys } from 'utils/configHelper'
import { friendlyFormatShortcut, JSONRequest, parseURLSearch } from 'utils/general'
import keyHelper from 'utils/keyHelper'
import { version } from '../../package.json'
import { friendlyFormatShortcut } from 'utils/general'
import { useStates } from 'utils/hooks'
import * as keyHelper from 'utils/keyHelper'
import { SimpleField, SimpleFieldInput } from './MoreOption'
const WIKI_HOME_LINK = 'https://github.com/EnixCoda/Gitako/wiki'
const wikiLinks = {
@ -19,348 +19,222 @@ const wikiLinks = {
const ACCESS_TOKEN_REGEXP = /^[0-9a-f]{40}$/
type Props = {
accessToken?: string
activated: boolean
onAccessTokenChange: (accessToken: string) => void
onShortcutChange: (shortcut: string) => void
setCopyFile: (copyFileButton: Props['copyFileButton']) => void
setCopySnippet: (copySnippetButton: Props['copySnippetButton']) => void
setCompressSingleton: (compressSingletonFolder: Props['compressSingletonFolder']) => void
setIntelligentToggle: (intelligentToggle: Props['intelligentToggle']) => void
toggleShowSettings: () => void
toggleShowSideBarShortcut?: string
} & Pick<
Config,
'compressSingletonFolder' | 'copyFileButton' | 'copySnippetButton' | 'intelligentToggle'
>
type State = {
accessToken?: string
accessTokenHint: React.ReactNode
shortcutHint: string
toggleShowSideBarShortcut?: string
reloadHint: React.ReactNode
varyOptions: {
key: string
label: string
onChange: (e: React.FormEvent<HTMLInputElement>) => Promise<void> | void
getValue: () => boolean
wikiLink?: string
description?: string
}[]
}
export default class SettingsBar extends React.PureComponent<Props, State> {
state = {
accessToken: '',
accessTokenHint: '',
shortcutHint: '',
toggleShowSideBarShortcut: this.props.toggleShowSideBarShortcut,
reloadHint: '',
varyOptions: [
{
key: 'compress-singleton',
label: 'Compress singleton folder',
onChange: this.createOnToggleChecked(
configKeys.compressSingletonFolder,
this.props.setCompressSingleton,
),
getValue: () => this.props.compressSingletonFolder,
wikiLink: wikiLinks.compressSingletonFolder,
},
{
key: 'copy-file',
label: 'Copy File Shortcut',
onChange: this.createOnToggleChecked(configKeys.copyFileButton, this.props.setCopyFile),
getValue: () => this.props.copyFileButton,
wikiLink: wikiLinks.copyFileButton,
},
{
key: 'copy-snippet',
label: 'Copy Snippet Shortcut',
onChange: this.createOnToggleChecked(
configKeys.copySnippetButton,
this.props.setCopySnippet,
),
getValue: () => this.props.copySnippetButton,
wikiLink: wikiLinks.copySnippet,
},
{
key: 'intelligent-toggle',
label: 'Intelligent Toggle',
onChange: async (e: React.FormEvent<HTMLInputElement>) => {
const { checked } = e.currentTarget
const intelligentToggle = checked ? null : true
await configHelper.setOne(configKeys.intelligentToggle, intelligentToggle)
this.props.setIntelligentToggle(intelligentToggle)
},
getValue: () => this.props.intelligentToggle === null,
description: `Gitako will open/close automatically according to page content when this is enabled.`,
},
],
}
const moreFields: SimpleField[] = [
{
key: 'compressSingletonFolder',
label: 'Compress singleton folder',
wikiLink: wikiLinks.compressSingletonFolder,
},
{
key: 'copyFileButton',
label: 'Copy File Shortcut',
wikiLink: wikiLinks.copyFileButton,
},
{
key: 'copySnippetButton',
label: 'Copy Snippet Shortcut',
wikiLink: wikiLinks.copySnippet,
},
{
key: 'intelligentToggle',
label: 'Intelligent Toggle',
description: `Gitako will open/close automatically according to page content when this is enabled.`,
overwrite: {
value: enabled => enabled === null,
onChange: checked => (checked ? null : true),
},
},
]
componentDidMount() {
if (!this.props.accessToken) this.trySetUpAccessTokenWithCode()
}
function SettingsBarContent() {
const configContext = useConfigs()
const hasAccessToken = Boolean(configContext.val.access_token)
const useAccessToken = useStates('')
const useAccessTokenHint = useStates<React.ReactNode>('')
const useShortcutHint = useStates('')
const useToggleShowSideBarShortcut = useStates(configContext.val.shortcut)
const useReloadHint = useStates<React.ReactNode>('')
componentWillReceiveProps({ toggleShowSideBarShortcut }: Props) {
if (toggleShowSideBarShortcut !== this.props.toggleShowSideBarShortcut) {
this.setState({ toggleShowSideBarShortcut })
}
}
const { val: accessTokenHint } = useAccessTokenHint
const { val: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
const { val: shortcutHint } = useShortcutHint
const { val: accessToken } = useAccessToken
const { val: reloadHint } = useReloadHint
private async 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 } = res
if (scope !== 'repo' || !accessToken) {
throw new Error(`Cannot resolve token response: '${JSON.stringify(res)}'`)
}
window.history.pushState({}, 'removed code', window.location.pathname.replace(/#.*$/, ''))
this.setState({ accessToken }, () => this.saveToken(''))
React.useEffect(() => {
useToggleShowSideBarShortcut.set(configContext.val.shortcut)
}, [configContext.val.shortcut])
React.useEffect(() => {
// clear input when access token updates
useAccessToken.set('')
}, [configContext.val.access_token])
const onInputAccessToken = React.useCallback(
({ currentTarget: { value } }: React.FormEvent<HTMLInputElement>) => {
useAccessToken.set(value)
useAccessTokenHint.set(
ACCESS_TOKEN_REGEXP.test(value) ? '' : 'This token is in unknown format.',
)
},
[],
)
const onPressAccessToken = React.useCallback(({ key }: React.KeyboardEvent) => {
if (key === 'Enter') saveToken()
}, [])
const saveToken = React.useCallback(
async (hint?: typeof useAccessTokenHint.val) => {
if (accessToken) {
configContext.set({ access_token: accessToken })
useAccessToken.set('')
useAccessTokenHint.set(
hint || (
<span>
<a href="#" onClick={() => window.location.reload()}>
Reload
</a>{' '}
to activate!
</span>
),
)
}
} catch (err) {
raiseError(err)
}
}
},
[accessToken],
)
onInputAccessToken = (event: React.FormEvent<HTMLInputElement>) => {
const { value } = event.currentTarget
this.setState({
accessToken: value,
accessTokenHint: ACCESS_TOKEN_REGEXP.test(value) ? '' : 'This token is in unknown format.',
})
}
onPressAccessToken = (event: React.KeyboardEvent) => {
const { key } = event
if (key === 'Enter') {
this.saveToken()
}
}
saveToken = async (
hint: State['accessTokenHint'] = (
<span>
<a href="#" onClick={() => window.location.reload()}>
Reload
</a>{' '}
to activate!
</span>
),
) => {
const { onAccessTokenChange } = this.props
const { accessToken } = this.state
if (accessToken) {
await configHelper.setOne(configKeys.accessToken, accessToken)
onAccessTokenChange(accessToken)
this.setState({
accessToken: '',
accessTokenHint: hint,
})
}
}
clearToken = async () => {
const { onAccessTokenChange } = this.props
await configHelper.setOne(configKeys.accessToken, '')
onAccessTokenChange('')
this.setState({ accessToken: '' })
}
saveShortcut = async () => {
const { onShortcutChange } = this.props
const { toggleShowSideBarShortcut } = this.state
await configHelper.setOne(configKeys.shortcut, toggleShowSideBarShortcut)
const saveShortcut = React.useCallback(async () => {
const { val: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
configContext.set({ shortcut: toggleShowSideBarShortcut })
if (typeof toggleShowSideBarShortcut === 'string') {
onShortcutChange(toggleShowSideBarShortcut)
this.setState({
shortcutHint: 'Shortcut is saved!',
})
useShortcutHint.set('Shortcut is saved!')
}
}
}, [useToggleShowSideBarShortcut.val])
onShortCutInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const onShortCutInputKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault()
e.stopPropagation()
// Clear shortcut with backspace
const shortcut = e.key === 'Backspace' ? '' : keyHelper.parseEvent(e)
this.setState({ toggleShowSideBarShortcut: shortcut })
}
useToggleShowSideBarShortcut.set(shortcut)
}, [])
showReloadHint = () => {
this.setState({
reloadHint: (
<span>
Saved,{' '}
<a href="#" onClick={() => window.location.reload()}>
reload
</a>{' '}
to apply.
</span>
),
})
}
createOnToggleChecked(
configKey: configKeys,
set: (value: boolean) => void,
): (e: React.FormEvent<HTMLInputElement>) => Promise<void> {
return async e => {
const enabled = e.currentTarget.checked
await configHelper.setOne(configKey, enabled)
set(enabled)
this.showReloadHint()
}
}
render() {
const {
accessTokenHint,
toggleShowSideBarShortcut,
shortcutHint,
accessToken,
reloadHint,
varyOptions,
} = this.state
const { toggleShowSettings, activated } = this.props
const hasAccessToken = Boolean(this.props.accessToken)
return (
<div className={'gitako-settings-bar'}>
{activated && (
<React.Fragment>
<h3 className={'gitako-settings-bar-title'}>Settings</h3>
<div className={'gitako-settings-bar-content'}>
<div className={'shadow-shelter'} />
<div className={'gitako-settings-bar-content-section access-token'}>
<h4>
Access Token
<a href={wikiLinks.createAccessToken} target="_blank">
&nbsp;(?)
</a>
</h4>
{!hasAccessToken && (
<a
href="#"
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
}}
>
Create with OAuth (recommended)
</a>
)}
<div className={'access-token-input-control'}>
<input
className={'access-token-input form-control'}
disabled={hasAccessToken}
placeholder={hasAccessToken ? 'Your token is saved' : 'Or input here manually'}
value={accessToken}
onChange={this.onInputAccessToken}
onKeyPress={this.onPressAccessToken}
/>
{hasAccessToken && !accessToken ? (
<button className={'btn'} onClick={this.clearToken}>
Clear
</button>
) : (
<button
className={'btn'}
onClick={() => this.saveToken()}
disabled={!accessToken}
>
Save
</button>
)}
</div>
{accessTokenHint && <span className={'hint'}>{accessTokenHint}</span>}
</div>
<div className={'gitako-settings-bar-content-section toggle-shortcut'}>
<h4>Toggle Shortcut</h4>
<span>Set a combination of keys for toggling Gitako sidebar.</span>
<br />
<div className={'toggle-shortcut-input-control'}>
<input
className={'toggle-shortcut-input form-control'}
placeholder={'focus here and press the shortcut keys'}
value={friendlyFormatShortcut(toggleShowSideBarShortcut)}
onKeyDown={this.onShortCutInputKeyDown}
readOnly
/>
<button className={'btn'} onClick={this.saveShortcut}>
Save
</button>
</div>
{shortcutHint && <span className={'hint'}>{shortcutHint}</span>}
</div>
<div className={'gitako-settings-bar-content-section others'}>
<h4>More Options</h4>
{varyOptions.map(option => (
<React.Fragment key={option.key}>
<label htmlFor={option.key}>
<input
id={option.key}
name={option.key}
type={'checkbox'}
onChange={option.onChange}
checked={option.getValue()}
/>
&nbsp;{option.label}&nbsp;
{option.wikiLink ? (
<a href={option.wikiLink} target={'_blank'}>
(?)
</a>
) : (
option.description && (
<span className={'description'} title={option.description}>
(?)
</span>
)
)}
</label>
<br />
</React.Fragment>
))}
{reloadHint && <div className={'hint'}>{reloadHint}</div>}
</div>
<div className={'gitako-settings-bar-content-section issue'}>
<h4>Contact</h4>
<a href="https://github.com/EnixCoda/Gitako/issues" target="_blank">
Bug report / feature request.
</a>
</div>
</div>
</React.Fragment>
)}
<div className={'placeholder-row'}>
<a
className={'version'}
href={wikiLinks.changeLog}
target={'_blank'}
title={'Check out new features!'}
>
v{version}
</a>
{activated ? (
<Icon
type={'chevron-down'}
className={'hide-settings-icon'}
onClick={toggleShowSettings}
/>
) : (
<Icon type={'gear'} className={'show-settings-icon'} onClick={toggleShowSettings} />
return (
<>
<h3 className={'gitako-settings-bar-title'}>Settings</h3>
<div className={'gitako-settings-bar-content'}>
<div className={'shadow-shelter'} />
<div className={'gitako-settings-bar-content-section access-token'}>
<h4>
Access Token{' '}
<a href={wikiLinks.createAccessToken} target="_blank">
(?)
</a>
</h4>
{!hasAccessToken && (
<a
href="#"
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
}}
>
Create with OAuth (recommended)
</a>
)}
<div className={'access-token-input-control'}>
<input
className={'access-token-input form-control'}
disabled={hasAccessToken}
placeholder={hasAccessToken ? 'Your token is saved' : 'Or input here manually'}
value={accessToken}
onChange={onInputAccessToken}
onKeyPress={onPressAccessToken}
/>
{hasAccessToken && !accessToken ? (
<button className={'btn'} onClick={() => configContext.set({ access_token: '' })}>
Clear
</button>
) : (
<button className={'btn'} onClick={() => saveToken()} disabled={!accessToken}>
Save
</button>
)}
</div>
{accessTokenHint && <span className={'hint'}>{accessTokenHint}</span>}
</div>
<div className={'gitako-settings-bar-content-section toggle-shortcut'}>
<h4>Toggle Shortcut</h4>
<span>Set a combination of keys for toggling Gitako sidebar.</span>
<br />
<div className={'toggle-shortcut-input-control'}>
<input
className={'toggle-shortcut-input form-control'}
placeholder={'focus here and press the shortcut keys'}
value={friendlyFormatShortcut(toggleShowSideBarShortcut)}
onKeyDown={onShortCutInputKeyDown}
readOnly
/>
<button className={'btn'} onClick={saveShortcut}>
Save
</button>
</div>
{shortcutHint && <span className={'hint'}>{shortcutHint}</span>}
</div>
<div className={'gitako-settings-bar-content-section others'}>
<h4>More Options</h4>
{moreFields.map(field => (
<React.Fragment key={field.key}>
<SimpleFieldInput field={field} overwrite={field.overwrite} />
<br />
</React.Fragment>
))}
{reloadHint && <div className={'hint'}>{reloadHint}</div>}
</div>
<div className={'gitako-settings-bar-content-section issue'}>
<h4>Contact</h4>
<a href="https://github.com/EnixCoda/Gitako/issues" target="_blank">
Bug report / feature request.
</a>
</div>
</div>
)
}
</>
)
}
export function SettingsBar(props: Props) {
const { toggleShowSettings, activated } = props
return (
<div className={'gitako-settings-bar'}>
{activated && <SettingsBarContent />}
<div className={'header-row'}>
<a
className={'version'}
href={wikiLinks.changeLog}
target={'_blank'}
title={'Check out new features!'}
>
{VERSION}
</a>
{activated ? (
<Icon
type={'chevron-down'}
className={'hide-settings-icon'}
onClick={toggleShowSettings}
/>
) : (
<Icon type={'gear'} className={'show-settings-icon'} onClick={toggleShowSettings} />
)}
</div>
</div>
)
}

View file

@ -1,150 +1,198 @@
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 ToggleShowButton from 'components/ToggleShowButton'
import connect from 'driver/connect'
import { SideBar as SideBarCore } from 'driver/core'
import { ConnectorState } from 'driver/core/SideBar'
import { raiseError } from 'analytics'
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 { ToggleShowButton } from 'components/ToggleShowButton'
import { useConfigs } from 'containers/ConfigsContext'
import { connect } from 'driver/connect'
import { SideBarCore } from 'driver/core'
import { ConnectorState, Props } from 'driver/core/SideBar'
import { oauth } from 'env'
import * as React from 'react'
import cx from 'utils/cx'
import { cx } from 'utils/cx'
import * as DOMHelper from 'utils/DOMHelper'
import { JSONRequest, parseURLSearch } from 'utils/general'
import { useDidUpdate } from 'utils/hooks'
import * as keyHelper from 'utils/keyHelper'
import * as URLHelper from 'utils/URLHelper'
export type Props = {}
const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
const configContext = useConfigs()
const accessToken = props.configContext.val.access_token
class Gitako extends React.PureComponent<Props & ConnectorState> {
static defaultProps: Partial<Props & ConnectorState> = {
baseSize: 260,
shouldShow: false,
showSettings: false,
errorDueToAuth: false,
accessToken: '',
toggleShowSideBarShortcut: '',
compressSingletonFolder: true,
copyFileButton: true,
copySnippetButton: true,
disabled: false,
}
React.useEffect(() => {
const { init } = props
;(async function() {
if (!accessToken) {
const accessToken = await trySetUpAccessTokenWithCode()
configContext.set({ access_token: accessToken })
}
init()
})()
}, [])
componentWillMount() {
const { init } = this.props
init()
}
const onKeyDown = React.useCallback(
configContext.val.shortcut
? (e: KeyboardEvent) => {
const keys = keyHelper.parseEvent(e)
if (keys === configContext.val.shortcut) {
props.toggleShowSideBar()
}
}
: () => {},
[configContext.val.shortcut],
)
componentDidMount() {
const { useListeners } = this.props
useListeners(true)
}
React.useEffect(() => {
if (props.disabled) return
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [props.disabled, onKeyDown])
componentWillUnmount() {
const { useListeners } = this.props
useListeners(false)
}
const updateMeta = React.useCallback(() => {
if (props.disabled) return
DOMHelper.unmountTopProgressBar()
props.setMetaData({ ...props.metaData, ...URLHelper.parse() })
}, [props.disabled, props.metaData, configContext.val])
useOnPJAXComplete(updateMeta)
renderAccessDeniedError() {
return (
<div className={'description'}>
<h5>Access Denied</h5>
<p>
Due to{' '}
<a target="_blank" href="https://developer.github.com/v3/#rate-limiting">
limitation of GitHub
</a>{' '}
or{' '}
<a target="_blank" href="https://developer.github.com/v3/#authentication">
auth needs
</a>
, Gitako needs access token to continue. Please follow the instructions in the settings
panel below.
</p>
</div>
)
}
const attachCopyFileButton = React.useCallback(() => {
if (props.disabled) return
if (configContext.val.copyFileButton) return DOMHelper.attachCopyFileBtn()
}, [props.disabled, configContext.val.copyFileButton])
useOnPJAXComplete(attachCopyFileButton)
renderContent() {
const {
errorDueToAuth,
metaData,
treeData,
showSettings,
toggleShowSettings,
compressSingletonFolder,
accessToken,
} = this.props
return (
<div className={'gitako-side-bar-content'}>
{metaData && <MetaBar metaData={metaData} />}
{errorDueToAuth
? this.renderAccessDeniedError()
: metaData && (
<FileExplorer
compressSingletonFolder={compressSingletonFolder}
toggleShowSettings={toggleShowSettings}
metaData={metaData}
treeData={treeData}
freeze={showSettings}
accessToken={accessToken}
/>
)}
</div>
)
}
const attachCopySnippetButton = React.useCallback(() => {
if (props.disabled) return
if (configContext.val.copySnippetButton) return DOMHelper.attachCopySnippet()
}, [props.disabled, configContext.val.copySnippetButton])
useOnPJAXComplete(attachCopySnippetButton)
render() {
const {
baseSize,
error,
shouldShow,
showSettings,
accessToken,
compressSingletonFolder,
copyFileButton,
copySnippetButton,
intelligentToggle,
toggleShowSideBarShortcut,
logoContainerElement,
toggleShowSideBar,
toggleShowSettings,
onShortcutChange,
onAccessTokenChange,
setCompressSingleton,
setCopyFile,
setCopySnippet,
setIntelligentToggle,
} = this.props
return (
<div className={'gitako-side-bar'}>
<Portal into={logoContainerElement}>
<ToggleShowButton
error={error}
shouldShow={shouldShow}
toggleShowSideBar={toggleShowSideBar}
/>
</Portal>
<Resizable className={cx({ hidden: error || !shouldShow })} baseSize={baseSize}>
<div className={'gitako-side-bar-body'}>
{this.renderContent()}
<SettingsBar
toggleShowSettings={toggleShowSettings}
onShortcutChange={onShortcutChange}
onAccessTokenChange={onAccessTokenChange}
activated={showSettings}
accessToken={accessToken}
toggleShowSideBarShortcut={toggleShowSideBarShortcut}
compressSingletonFolder={compressSingletonFolder}
copyFileButton={copyFileButton}
copySnippetButton={copySnippetButton}
intelligentToggle={intelligentToggle}
setCompressSingleton={setCompressSingleton}
setCopyFile={setCopyFile}
setCopySnippet={setCopySnippet}
setIntelligentToggle={setIntelligentToggle}
/>
React.useEffect(() => {
if (configContext.val.intelligentToggle === null) {
props.setShouldShow(URLHelper.isInCodePage(props.metaData))
}
}, [props.metaData, configContext.val.intelligentToggle])
React.useEffect(() => {
if (configContext.val.copyFileButton) return DOMHelper.attachCopyFileBtn() || undefined // undefined is friendlier to React
}, [configContext.val.copyFileButton])
React.useEffect(() => {
if (configContext.val.copySnippetButton) return DOMHelper.attachCopySnippet() || undefined // undefined is friendlier to React
}, [configContext.val.copySnippetButton])
// init again when setting new accessToken
useDidUpdate(() => {
props.init()
}, [accessToken])
const {
errorDueToAuth,
metaData,
treeData,
baseSize,
error,
shouldShow,
showSettings,
logoContainerElement,
toggleShowSideBar,
toggleShowSettings,
} = props
return (
<div className={'gitako-side-bar'}>
<Portal into={logoContainerElement}>
<ToggleShowButton
error={error}
shouldShow={shouldShow}
toggleShowSideBar={toggleShowSideBar}
/>
</Portal>
<Resizable className={cx({ hidden: error || !shouldShow })} baseSize={baseSize}>
<div className={'gitako-side-bar-body'}>
<div className={'gitako-side-bar-content'}>
{metaData && <MetaBar metaData={metaData} />}
{errorDueToAuth
? renderAccessDeniedError()
: metaData && (
<FileExplorer
toggleShowSettings={toggleShowSettings}
metaData={metaData}
treeData={treeData}
freeze={showSettings}
accessToken={accessToken}
/>
)}
</div>
</Resizable>
</div>
)
}
<SettingsBar toggleShowSettings={toggleShowSettings} activated={showSettings} />
</div>
</Resizable>
</div>
)
}
export default connect<Props, ConnectorState>(SideBarCore)(Gitako)
RawGitako.defaultProps = {
baseSize: 260,
shouldShow: false,
showSettings: false,
errorDueToAuth: false,
disabled: false,
}
export const SideBar = connect(SideBarCore)(RawGitako)
function useEvent<
T extends {
addEventListener: Function
}
>(target: T, event: string, callback: () => void, deps: React.DependencyList = []) {
React.useEffect(() => {
window.addEventListener('pjax:complete', callback)
return () => window.removeEventListener('pjax:complete', callback)
}, [callback, ...deps])
}
const useOnPJAXComplete = useEvent.bind(null, window, 'pjax:complete')
function renderAccessDeniedError() {
return (
<div className={'description'}>
<h5>Access Denied</h5>
<p>
Due to{' '}
<a target="_blank" href="https://developer.github.com/v3/#rate-limiting">
limitation of GitHub
</a>{' '}
or{' '}
<a target="_blank" href="https://developer.github.com/v3/#authentication">
auth needs
</a>
, Gitako needs access token to continue. Please follow the instructions in the settings
panel below.
</p>
</div>
)
}
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 } = res
if (scope !== 'repo' || !accessToken) {
throw new Error(`Cannot resolve token response: '${JSON.stringify(res)}'`)
}
window.history.pushState({}, 'removed code', window.location.pathname.replace(/#.*$/, ''))
return accessToken
}
} catch (err) {
raiseError(err)
}
}

View file

@ -11,7 +11,7 @@ type Props = {
children(size: Partial<Size>): React.ReactNode
} & React.HTMLAttributes<HTMLElement>
export default function SizeObserver({ type = 'div', children, ...rest }: Props) {
export function SizeObserver({ type = 'div', children, ...rest }: Props) {
const ref = React.useRef<any>()
const [size, setSize] = React.useState<Partial<Size>>({
@ -19,6 +19,16 @@ export default function SizeObserver({ type = 'div', children, ...rest }: Props)
height: undefined,
})
const safeSetSize = React.useCallback(function safeSetSize(rect: DOMRectReadOnly) {
// requestAnimationFrame fixes "ResizeObserver loop limit exceeded" error
requestAnimationFrame(() =>
setSize({
width: rect.width,
height: rect.height,
}),
)
}, [])
React.useLayoutEffect(() => {
if (features.resize) {
const observer = new window.ResizeObserver(entries => {
@ -43,14 +53,4 @@ export default function SizeObserver({ type = 'div', children, ...rest }: Props)
const props: any = { ...rest, ref } // :)
return React.createElement(type, props, children(size))
function safeSetSize(rect: DOMRectReadOnly) {
// requestAnimationFrame fixes "ResizeObserver loop limit exceeded" error
requestAnimationFrame(() =>
setSize({
width: rect.width,
height: rect.height,
}),
)
}
}

View file

@ -1,13 +1,14 @@
import { Icon } from 'components/Icon'
import * as React from 'react'
import Icon from 'components/Icon'
import cx from 'utils/cx'
import { cx } from 'utils/cx'
type Props = {
error?: string
shouldShow: boolean
toggleShowSideBar: React.MouseEventHandler
}
export default function Logo({ error, shouldShow, toggleShowSideBar }: Props) {
export function ToggleShowButton({ error, shouldShow, toggleShowSideBar }: Props) {
return (
<div
className={cx('gitako-toggle-show-button-wrapper', {

View file

@ -0,0 +1,41 @@
import * as React from 'react'
import * as configsHelper from 'utils/configHelper'
import { Config } from 'utils/configHelper'
type Props = {}
type ContextShape = PartialValSet<Config>
export type ConfigsContextShape = ContextShape
export const ConfigsContext = React.createContext<ContextShape | null>(null)
export function ConfigsContextWrapper(props: React.PropsWithChildren<Props>) {
const [configs, setConfigs] = React.useState<Config | null>(null)
React.useEffect(() => {
configsHelper.get().then(setConfigs)
}, [])
const set = React.useCallback(
(updatedConfigs: Partial<Config>) => {
const mergedConfigs = { ...configs, ...updatedConfigs } as Config
configsHelper.set(mergedConfigs)
setConfigs(mergedConfigs)
},
[configs, setConfigs],
)
if (configs === null) return null
return (
<ConfigsContext.Provider value={{ val: configs, set }}>
{props.children}
</ConfigsContext.Provider>
)
}
export const useConfigs = useNonNullContext(ConfigsContext)
function useNonNullContext<T, R extends Exclude<T, null>>(theContext: React.Context<T>): () => R {
return () => {
const context = React.useContext(theContext)
if (context === null) throw new Error(`Empty context`)
return context as R
}
}

View file

@ -59,14 +59,10 @@
background-image: url('~@primer/octicons/build/svg/clippy.svg?inline');
background-position: center;
background-repeat: no-repeat;
}
&.success {
.icon {
&.success {
background-image: url('~@primer/octicons/build/svg/check.svg?inline');
}
}
&.fail {
.icon {
&.fail {
background-image: url('~@primer/octicons/build/svg/x.svg?inline');
}
}
@ -473,7 +469,7 @@
color: #6a737d;
}
}
.placeholder-row {
.header-row {
flex-shrink: 0;
display: flex;
justify-content: space-between;

View file

@ -1,9 +1,8 @@
import { withErrorLog } from 'analytics'
import { Gitako } from 'components/Gitako'
import { addMiddleware } from 'driver/connect'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import Gitako from 'components/Gitako'
import { addMiddleware } from 'driver/connect'
import { withErrorLog } from 'analytics'
import './content.less'
addMiddleware(withErrorLog)

View file

@ -31,7 +31,7 @@ function run<M extends Method>([method, args]: [M, Parameters<M>]) {
}
export type DispatchState<Props, State> = React.Component<Props, State>['setState']
export type GetState<State> = () => State
export type GetState<Props, State> = () => [State, Props]
export type TriggerOtherMethod<Props, State> = <Args extends any[]>(
methodCreator: MethodCreator<Props, State, Args>,
...args: Parameters<ReturnType<MethodCreator<Props, State, Args>>>
@ -39,7 +39,7 @@ export type TriggerOtherMethod<Props, State> = <Args extends any[]>(
export type Dispatch<Props, State> = {
set: DispatchState<Props, State>
get: GetState<State>
get: GetState<Props, State>
call: TriggerOtherMethod<Props, State>
}
@ -47,7 +47,7 @@ export type MethodCreator<Props, State, Args extends any[] = []> = (
dispatch: Dispatch<Props, State>,
) => Method<Args>
type Sources<P, S> = {
export type Sources<P, S> = {
[key: string]: MethodCreator<P, S, any>
}
type WrappedMethods = {
@ -75,7 +75,7 @@ function link<P, S>(instance: React.Component<P, S>, sources: Sources<P, S>): Wr
const dispatchState: DispatchState<P, S> = (updater, callback) => {
instance.setState(updater, callback)
}
const prepareState: GetState<S> = () => instance.state
const prepareState: GetState<P, S> = () => [instance.state, instance.props]
const dispatch: Dispatch<P, S> = {
call: dispatchCall,
get: prepareState,
@ -93,20 +93,20 @@ function link<P, S>(instance: React.Component<P, S>, sources: Sources<P, S>): Wr
return wrappedMethods
}
export default function connect<BaseP, ExtraP>(mapping: Sources<BaseP, ExtraP>) {
return function linkComponent<S>(
ComponentClass: React.ComponentClass<BaseP & ExtraP, S>,
): React.ComponentClass<BaseP, ExtraP> {
return class AwesomeApp extends React.PureComponent<BaseP, ExtraP> {
static displayName = `Connected(${ComponentClass.displayName || ComponentClass.name})`
static defaultProps = ComponentClass.defaultProps
export function connect<BaseP, ExtraP>(mapping: Sources<BaseP, ExtraP>) {
return function linkComponent<State, ComponentType extends React.ComponentType<BaseP & ExtraP>>(
Component: ComponentType,
) {
return class ConnectedComponent extends React.PureComponent<BaseP, ExtraP, State> {
static displayName = `Connected(${Component.displayName || Component.name})`
static defaultProps = Component.defaultProps
state = {} as ExtraP
connectedMethods = link<BaseP, ExtraP>(this, mapping) as WrappedMethods
state: ExtraP = {} as ExtraP
connectedMethods: WrappedMethods = link<BaseP, ExtraP>(this, mapping)
render() {
const props = Object.assign({}, this.props, this.connectedMethods, this.state)
return React.createElement(ComponentClass, props)
return React.createElement(Component, props)
}
}
}

View file

@ -1,13 +1,22 @@
import { Props } from 'components/FileExplorer'
import { GetCreatedMethod, MethodCreator } from 'driver/connect'
import * as ini from 'ini'
import { Base64 } from 'js-base64'
import DOMHelper from 'utils/DOMHelper'
import { Config } from 'utils/configHelper'
import * as DOMHelper from 'utils/DOMHelper'
import { findNode, searchKeyToRegexps } from 'utils/general'
import GitHubHelper, { BlobData } from 'utils/GitHubHelper'
import treeParser from 'utils/treeParser'
import URLHelper from 'utils/URLHelper'
import VisibleNodesGenerator, { TreeNode, VisibleNodes } from 'utils/VisibleNodesGenerator'
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'
export type Props = {
treeData?: GitHubHelper.TreeData
metaData: GitHubHelper.MetaData
freeze: boolean
accessToken: string | undefined
toggleShowSettings: React.MouseEventHandler
}
export type ConnectorState = {
stateText: string
@ -49,7 +58,7 @@ let visibleNodesGenerator: VisibleNodesGenerator
type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, ConnectorState, Args>
const init: BoundMethodCreator = dispatch => () =>
export const init: BoundMethodCreator = dispatch => () =>
dispatch.call(setStateText, 'Fetching File List...')
const githubSubModuleURLRegex = {
@ -127,8 +136,8 @@ function handleParsed(root: TreeNode, parsed: Parsed) {
})
}
const setUpTree: BoundMethodCreator<
[Pick<Props, 'treeData' | 'metaData' | 'compressSingletonFolder' | 'accessToken'>]
export const setUpTree: BoundMethodCreator<
[Pick<Props, 'treeData' | 'metaData' | 'accessToken'> & Pick<Config, 'compressSingletonFolder'>]
> = dispatch => async ({ treeData, metaData, compressSingletonFolder, accessToken }) => {
if (!treeData) return
dispatch.call(setStateText, 'Rendering File List...')
@ -158,22 +167,22 @@ const setUpTree: BoundMethodCreator<
dispatch.call(goTo, URLHelper.getCurrentPath(metaData.branchName))
}
const execAfterRender: BoundMethodCreator = dispatch => () => {
export const execAfterRender: BoundMethodCreator = dispatch => () => {
for (const task of tasksAfterRender) {
task()
}
tasksAfterRender.length = 0
}
const setStateText: BoundMethodCreator<[ConnectorState['stateText']]> = dispatch => (
export const setStateText: BoundMethodCreator<[ConnectorState['stateText']]> = dispatch => (
text: string,
) =>
dispatch.set({
stateText: text,
})
const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch => event => {
const { searched, visibleNodes } = dispatch.get()
export const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch => event => {
const [{ searched, visibleNodes }] = dispatch.get()
if (!visibleNodes) return
const { nodes, focusedNode, expandedNodes, depths } = visibleNodes
function handleVerticalMove(index: number) {
@ -277,16 +286,17 @@ const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch => eve
}
}
const onFocusSearchBar: BoundMethodCreator = dispatch => () => dispatch.call(focusNode, null, false)
export const onFocusSearchBar: BoundMethodCreator = dispatch => () =>
dispatch.call(focusNode, null, false)
const search: BoundMethodCreator<[string]> = dispatch => searchKey => {
export const search: BoundMethodCreator<[string]> = dispatch => searchKey => {
dispatch.set({ searchKey, searched: searchKey !== '' })
const regexps = searchKeyToRegexps(searchKey)
visibleNodesGenerator.search(regexps)
dispatch.call(updateVisibleNodes)
}
const goTo: BoundMethodCreator<[string[]]> = dispatch => async currentPath => {
export const goTo: BoundMethodCreator<[string[]]> = dispatch => async currentPath => {
visibleNodesGenerator.search([])
tasksAfterRender.push(() => {
const nodeExpandedTo = visibleNodesGenerator.expandTo(currentPath.join('/'))
@ -298,12 +308,15 @@ const goTo: BoundMethodCreator<[string[]]> = dispatch => async currentPath => {
dispatch.set({ searchKey: '', searched: false })
}
const setExpand: BoundMethodCreator<[TreeNode, boolean]> = dispatch => (node, expand = false) => {
export const setExpand: BoundMethodCreator<[TreeNode, boolean]> = dispatch => (
node,
expand = false,
) => {
visibleNodesGenerator.setExpand(node, expand)
dispatch.call(focusNode, node, false)
}
const toggleNodeExpansion: BoundMethodCreator<[TreeNode, boolean]> = dispatch => (
export const toggleNodeExpansion: BoundMethodCreator<[TreeNode, boolean]> = dispatch => (
node,
skipScrollToNode,
) => {
@ -312,17 +325,16 @@ const toggleNodeExpansion: BoundMethodCreator<[TreeNode, boolean]> = dispatch =>
tasksAfterRender.push(DOMHelper.focusFileExplorer)
}
const focusNode: BoundMethodCreator<[TreeNode | null, boolean]> = dispatch => (
export const focusNode: BoundMethodCreator<[TreeNode | null, boolean]> = dispatch => (
node: TreeNode | null,
skipScroll = false,
) => {
const { visibleNodes } = dispatch.get()
const [{ visibleNodes }] = dispatch.get()
if (!visibleNodes) return
visibleNodesGenerator.focusNode(node)
dispatch.call(updateVisibleNodes)
}
const onNodeClick: BoundMethodCreator<[TreeNode]> = dispatch => node => {
export const onNodeClick: BoundMethodCreator<[TreeNode]> = dispatch => node => {
if (node.type === 'tree') {
dispatch.call(toggleNodeExpansion, node, true)
} else if (node.type === 'blob') {
@ -335,23 +347,7 @@ const onNodeClick: BoundMethodCreator<[TreeNode]> = dispatch => node => {
}
}
const updateVisibleNodes: BoundMethodCreator = dispatch => () => {
export const updateVisibleNodes: BoundMethodCreator = dispatch => () => {
const { visibleNodes } = visibleNodesGenerator
dispatch.set({ visibleNodes })
}
export default {
init,
setUpTree,
execAfterRender,
setStateText,
handleKeyDown,
onFocusSearchBar,
search,
setExpand,
goTo,
toggleNodeExpansion,
focusNode,
onNodeClick,
updateVisibleNodes,
}

View file

@ -1,18 +1,13 @@
import { Props } from 'components/SideBar'
import { ConfigsContextShape } from 'containers/ConfigsContext'
import { GetCreatedMethod, MethodCreator } from 'driver/connect'
import configHelper, { Config, configKeys } from 'utils/configHelper'
import DOMHelper from 'utils/DOMHelper'
import GitHubHelper, {
API_RATE_LIMIT,
BAD_CREDENTIALS,
BLOCKED_PROJECT,
EMPTY_PROJECT,
MetaData,
NOT_FOUND,
TreeData,
} from 'utils/GitHubHelper'
import keyHelper from 'utils/keyHelper'
import URLHelper from 'utils/URLHelper'
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
}
export type ConnectorState = {
// error message
@ -32,30 +27,18 @@ export type ConnectorState = {
initializingPromise: Promise<void> | null
} & {
init: GetCreatedMethod<typeof init>
onPJAXEnd: GetCreatedMethod<typeof onPJAXEnd>
onKeyDown: GetCreatedMethod<typeof onKeyDown>
setMetaData: GetCreatedMethod<typeof setMetaData>
setShouldShow: GetCreatedMethod<typeof setShouldShow>
toggleShowSideBar: GetCreatedMethod<typeof toggleShowSideBar>
toggleShowSettings: GetCreatedMethod<typeof toggleShowSettings>
useListeners: GetCreatedMethod<typeof useListeners>
onAccessTokenChange: GetCreatedMethod<typeof onAccessTokenChange>
onShortcutChange: GetCreatedMethod<typeof onShortcutChange>
setCopyFile: GetCreatedMethod<typeof setCopyFile>
setCopySnippet: GetCreatedMethod<typeof setCopySnippet>
setCompressSingleton: GetCreatedMethod<typeof setCompressSingleton>
setIntelligentToggle: GetCreatedMethod<typeof setIntelligentToggle>
} & {
baseSize: number
toggleShowSideBarShortcut?: string
accessToken?: string
} & Pick<
Config,
'compressSingletonFolder' | 'copyFileButton' | 'copySnippetButton' | 'intelligentToggle'
>
}
type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, ConnectorState, Args>
const init: BoundMethodCreator = dispatch => async () => {
const { initializingPromise } = dispatch.get()
export const init: BoundMethodCreator = dispatch => async () => {
const [{ initializingPromise }] = dispatch.get()
if (initializingPromise) await initializingPromise
let done: any = null // cannot use type `(() => void) | null` here
@ -82,24 +65,10 @@ const init: BoundMethodCreator = dispatch => async () => {
}
metaData.branchName = detectedBranchName || 'master'
dispatch.call(setMetaData, metaData)
const {
sideBarWidth,
access_token: accessToken,
shortcut,
compressSingletonFolder,
copyFileButton,
copySnippetButton,
intelligentToggle,
} = await configHelper.getAll()
DOMHelper.decorateGitHubPageContent({ copyFileButton, copySnippetButton })
const [, { configContext }] = dispatch.get()
const { sideBarWidth, access_token: accessToken, intelligentToggle } = configContext.val
dispatch.set({
baseSize: sideBarWidth,
accessToken,
toggleShowSideBarShortcut: shortcut,
compressSingletonFolder,
copyFileButton,
copySnippetButton,
intelligentToggle,
})
if (!metaData.branchName || !metaData.userName) return
@ -159,146 +128,57 @@ const init: BoundMethodCreator = dispatch => async () => {
}
}
const handleError: BoundMethodCreator<[Error]> = dispatch => async err => {
if (err.message === EMPTY_PROJECT) {
export const handleError: BoundMethodCreator<[Error]> = dispatch => async err => {
if (err.message === GitHubHelper.EMPTY_PROJECT) {
dispatch.call(setError, 'This project seems to be empty.')
} else if (err.message === BLOCKED_PROJECT) {
} else if (err.message === GitHubHelper.BLOCKED_PROJECT) {
dispatch.call(setError, 'This project is blocked.')
} else if (
err.message === NOT_FOUND ||
err.message === BAD_CREDENTIALS ||
err.message === API_RATE_LIMIT
err.message === GitHubHelper.NOT_FOUND ||
err.message === GitHubHelper.BAD_CREDENTIALS ||
err.message === GitHubHelper.API_RATE_LIMIT
) {
dispatch.set({ errorDueToAuth: true })
dispatch.call(setShowSettings, true)
dispatch.call(setShouldShow, true)
} else {
dispatch.call(useListeners, false)
dispatch.call(setError, 'Gitako ate a bug, but it should recovery soon!')
throw err
}
}
const onPJAXEnd: BoundMethodCreator = dispatch => () => {
const { metaData, copyFileButton, copySnippetButton, intelligentToggle } = dispatch.get()
DOMHelper.unmountTopProgressBar()
DOMHelper.decorateGitHubPageContent({ copyFileButton, copySnippetButton })
const mergedMetaData = { ...metaData, ...URLHelper.parse() }
dispatch.call(setMetaData, mergedMetaData)
if (intelligentToggle === null) {
dispatch.call(setShouldShow, URLHelper.isInCodePage(mergedMetaData))
}
}
const onKeyDown: BoundMethodCreator<[KeyboardEvent]> = dispatch => e => {
const { toggleShowSideBarShortcut } = dispatch.get()
if (toggleShowSideBarShortcut) {
const keys = keyHelper.parseEvent(e)
if (keys === toggleShowSideBarShortcut) {
dispatch.call(toggleShowSideBar)
}
}
}
const toggleShowSideBar: BoundMethodCreator = dispatch => () => {
const { intelligentToggle } = dispatch.get()
const shouldShow = !dispatch.get().shouldShow
dispatch.call(setShouldShow, shouldShow)
export const toggleShowSideBar: BoundMethodCreator = dispatch => () => {
const [{ shouldShow }, { configContext }] = dispatch.get()
dispatch.call(setShouldShow, !shouldShow)
const {
val: { intelligentToggle },
} = configContext
if (intelligentToggle !== null) {
dispatch.call(setIntelligentToggle, shouldShow)
configContext.set({ intelligentToggle: !shouldShow })
}
}
const setShouldShow: BoundMethodCreator<
[ConnectorState['shouldShow']]
> = dispatch => shouldShow => {
export const setShouldShow: BoundMethodCreator<[
ConnectorState['shouldShow'],
]> = dispatch => shouldShow => {
dispatch.set({ shouldShow }, shouldShow ? DOMHelper.focusFileExplorer : undefined)
DOMHelper.setBodyIndent(shouldShow)
}
const setError: BoundMethodCreator<[ConnectorState['error']]> = dispatch => error => {
export const setError: BoundMethodCreator<[ConnectorState['error']]> = dispatch => error => {
dispatch.set({ error })
dispatch.call(setShouldShow, false)
}
const toggleShowSettings: BoundMethodCreator = dispatch => () =>
export const toggleShowSettings: BoundMethodCreator = dispatch => () =>
dispatch.set(({ showSettings }) => ({
showSettings: !showSettings,
}))
const setShowSettings: BoundMethodCreator<
[ConnectorState['showSettings']]
> = dispatch => showSettings => dispatch.set({ showSettings })
export const setShowSettings: BoundMethodCreator<[
ConnectorState['showSettings'],
]> = dispatch => showSettings => dispatch.set({ showSettings })
const onAccessTokenChange: BoundMethodCreator<
[ConnectorState['accessToken']]
> = dispatch => accessToken => {
dispatch.set({ accessToken })
// reload when setting new accessToken
if (accessToken) {
dispatch.call(init)
}
}
const onShortcutChange: BoundMethodCreator<
[ConnectorState['toggleShowSideBarShortcut']]
> = dispatch => shortcut => dispatch.set({ toggleShowSideBarShortcut: shortcut })
const setMetaData: BoundMethodCreator<[ConnectorState['metaData']]> = dispatch => metaData =>
export const setMetaData: BoundMethodCreator<[ConnectorState['metaData']]> = dispatch => metaData =>
dispatch.set({ metaData })
const setCompressSingleton: BoundMethodCreator<
[ConnectorState['compressSingletonFolder']]
> = dispatch => compressSingletonFolder => dispatch.set({ compressSingletonFolder })
const setCopyFile: BoundMethodCreator<
[ConnectorState['copyFileButton']]
> = dispatch => copyFileButton => dispatch.set({ copyFileButton })
const setCopySnippet: BoundMethodCreator<
[ConnectorState['copySnippetButton']]
> = dispatch => copySnippetButton => dispatch.set({ copySnippetButton })
const setIntelligentToggle: BoundMethodCreator<
[ConnectorState['intelligentToggle']]
> = dispatch => intelligentToggle => {
configHelper.setOne(configKeys.intelligentToggle, intelligentToggle)
dispatch.set({ intelligentToggle })
}
const useListeners: BoundMethodCreator<[boolean]> = dispatch => {
const $onPJAXEnd = () => dispatch.call(onPJAXEnd)
const $onKeyDown = (e: KeyboardEvent) => dispatch.call(onKeyDown, e)
return on => {
const { disabled } = dispatch.get()
if (on && !disabled) {
window.addEventListener('pjax:complete', $onPJAXEnd)
window.addEventListener('keydown', $onKeyDown)
} else {
window.removeEventListener('pjax:complete', $onPJAXEnd)
window.removeEventListener('keydown', $onKeyDown)
}
}
}
export default {
init,
onPJAXEnd,
onKeyDown,
setShouldShow,
setShowSettings,
toggleShowSideBar,
toggleShowSettings,
onAccessTokenChange,
onShortcutChange,
setMetaData,
setCompressSingleton,
setCopyFile,
setCopySnippet,
setIntelligentToggle,
setError,
handleError,
useListeners,
}

View file

@ -1,2 +1,11 @@
export { default as SideBar } from './SideBar'
export { default as FileExplorer } from './FileExplorer'
import { Sources } from 'driver/connect'
import * as FileExplorer from './FileExplorer'
import {
ConnectorState as FileExplorerConnectorState,
Props as FileExplorerProps,
} from './FileExplorer'
import * as SideBar from './SideBar'
import { ConnectorState as SideBarConnectorState, Props as SideBarProps } from './SideBar'
export const FileExplorerCore: Sources<FileExplorerProps, FileExplorerConnectorState> = FileExplorer
export const SideBarCore: Sources<SideBarProps, SideBarConnectorState> = SideBar

View file

@ -9,3 +9,5 @@ export const oauth = {
clientId: process.env.GITHUB_OAUTH_CLIENT_ID,
clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET,
}
export const VERSION = process.env.VERSION

View file

@ -1 +1,3 @@
window.requestAnimationFrame = window.requestAnimationFrame.bind(window)
window.setTimeout = window.setTimeout.bind(window)
window.clearTimeout = window.clearTimeout.bind(window)

9
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
type ValSet<T> = {
val: T
set: (val: T) => void
}
type PartialValSet<T> = {
val: T
set: (val: Partial<T>) => void
}

View file

@ -1,17 +1,20 @@
/**
* 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
*/
function markGitakoReadyState() {
export function markGitakoReadyState() {
const readyClassName = 'gitako-ready'
document.body.classList.add(readyClassName)
}
@ -21,7 +24,7 @@ function markGitakoReadyState() {
* otherwise, hide the space
*/
export const bodySpacingClassName = 'with-gitako-spacing'
function setBodyIndent(shouldShowGitako: boolean) {
export function setBodyIndent(shouldShowGitako: boolean) {
if (shouldShowGitako) {
document.body.classList.add(bodySpacingClassName)
} else {
@ -35,10 +38,10 @@ function $<EE extends Element, E extends (element: EE) => any, O extends () => a
otherwise?: O,
): E extends never
? O extends never
? (Element | null)
? Element | null
: ReturnType<O> | null
: O extends never
? (ReturnType<E> | null)
? ReturnType<E> | null
: ReturnType<O> | ReturnType<E> {
const element = document.querySelector(selector)
if (element) {
@ -47,18 +50,18 @@ function $<EE extends Element, E extends (element: EE) => any, O extends () => a
return otherwise ? otherwise() : null
}
function isInCodePage() {
export function isInCodePage() {
const branchListSelector = '#branch-select-menu.branch-select-menu'
return Boolean($(branchListSelector))
}
function getBranches() {
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())
}
function getCurrentBranch() {
export function getCurrentBranch() {
const selectedBranchButtonSelector = '.repository-content .branch-select-menu summary'
const branchButtonElement: HTMLElement = $(selectedBranchButtonSelector)
if (branchButtonElement) {
@ -92,16 +95,15 @@ function getCurrentBranch() {
/**
* add the logo element into DOM
*
*/
function insertLogoMountPoint() {
export function insertLogoMountPoint() {
const logoSelector = '.gitako .gitako-logo'
return $(logoSelector) || createLogoMountPoint()
}
function createLogoMountPoint() {
const logoMountElement = document.createElement('div')
logoMountElement.setAttribute('class', 'gitako-logo-mount-point')
logoMountElement.classList.add('gitako-logo-mount-point')
document.body.appendChild(logoMountElement)
return logoMountElement
}
@ -110,7 +112,7 @@ function createLogoMountPoint() {
* content above the file navigation bar is same for all pages of the repo
* use this function to scroll down a bit to hide them
*/
function scrollToRepoContent() {
export function scrollToRepoContent() {
const repositoryContentSelector = '.repository-content'
// do NOT use behavior: smooth here as it will scroll horizontally
$(repositoryContentSelector, repositoryContentElement =>
@ -127,8 +129,8 @@ const pjax = new PJAX({
forceCache: true, // TODO: merge namespace, add forceCache
})
function loadWithPJAX(URL: string) {
NProgress.start()
export function loadWithPJAX(URL: string) {
mountTopProgressBar()
pjax.loadUrl(URL, { scrollTo: 0 })
}
@ -153,8 +155,8 @@ const PAGE_TYPES = {
*
* TODO: distinguish type 'preview'
*/
function getCurrentPageType() {
const blobWrapperSelector = '.repository-content .file .blob-wrapper table'
export function getCurrentPageType() {
const blobWrapperSelector = '.repository-content .blob-wrapper table'
const readmeSelector = '.repository-content .readme'
return (
$(blobWrapperSelector, () => PAGE_TYPES.RAW_TEXT) ||
@ -165,7 +167,7 @@ function getCurrentPageType() {
export const REPO_TYPE_PRIVATE = 'private'
export const REPO_TYPE_PUBLIC = 'public'
function getRepoPageType() {
export function getRepoPageType() {
const headerSelector = `#js-repo-pjax-container .pagehead.repohead h1`
return $(headerSelector, header => {
const repoPageTypes = [REPO_TYPE_PRIVATE, REPO_TYPE_PUBLIC]
@ -178,69 +180,54 @@ function getRepoPageType() {
})
}
/**
* 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
*/
function attachCopyFileBtn() {
/**
* get text content of raw text content
*/
function getCodeElement() {
if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) {
const codeContentSelector = '.repository-content .file .data table'
const codeContentElement = $(codeContentSelector)
if (!codeContentElement) {
raiseError(new Error('cannot find code content element'))
}
return codeContentElement
}
}
/**
* change inner text of copy file button to give feedback
* @param {element} copyFileBtn
* @param {string} text
*/
function setTempCopyFileBtnText(copyFileBtn: HTMLButtonElement, text: string) {
copyFileBtn.innerText = text
window.setTimeout(() => (copyFileBtn.innerText = 'Copy file'), 1000)
}
export function attachCopyFileBtn() {
if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) {
const btnGroupSelector = [
// the button group next to navigation bar
'.repository-content .file-navigation.js-zeroclipboard-container .BtnGroup',
// the button group in file content header
'.repository-content .file .file-header .file-actions .BtnGroup',
].join(', ')
const btnGroups = document.querySelectorAll(btnGroupSelector)
// the button group in file content header
const buttonGroupSelector = '.repository-content > .Box > .Box-header .BtnGroup'
const buttonGroups = document.querySelectorAll(buttonGroupSelector)
btnGroups.forEach(btnGroup => {
const copyFileBtn = document.createElement('button')
copyFileBtn.classList.add('btn', 'btn-sm', 'BtnGroup-item', 'copy-file-btn')
copyFileBtn.innerText = 'Copy file'
copyFileBtn.addEventListener('click', () => {
const codeElement = getCodeElement()
if (codeElement) {
if (copyElementContent(codeElement)) {
setTempCopyFileBtnText(copyFileBtn, 'Success!')
} else {
setTempCopyFileBtnText(copyFileBtn, 'Copy failed!')
}
}
})
btnGroup.insertBefore(copyFileBtn, btnGroup.lastChild)
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
* @param {element} element
* @returns {boolean} whether copy is successful
*/
function copyElementContent(element: Element) {
export function copyElementContent(element: Element): boolean {
let selection = window.getSelection()
if (selection) selection.removeAllRanges()
const range = document.createRange()
@ -253,67 +240,20 @@ function copyElementContent(element: Element) {
return isCopySuccessful
}
/**
* create a copy file content button `clippy`
* once mouse enters a code snippet of markdown, move clippy into it
* user can copy the snippet's content by click it
*
* TODO: 'reactify' it
*/
function createClippy() {
function setTempClippyIconFeedback(clippy: Element, type: 'success' | 'fail') {
const tempIconClassName = type === 'success' ? 'success' : 'fail'
clippy.classList.add(tempIconClassName)
window.setTimeout(() => {
clippy.classList.remove(tempIconClassName)
}, 1000)
}
/**
* <div class="clippy-wrapper">
* <button class="clippy">
* <i class="octicon octicon-clippy" />
* </button>
* </div>
*/
const clippyWrapper = document.createElement('div')
clippyWrapper.classList.add('clippy-wrapper')
const clippy = document.createElement('button')
clippy.classList.add('clippy')
const clippyIcon = document.createElement('i')
clippyIcon.classList.add('icon')
clippyWrapper.appendChild(clippy)
clippy.appendChild(clippyIcon)
// set clipboard with current code snippet element's content
clippy.addEventListener('click', function onClippyClick() {
if (copyElementContent(currentCodeSnippetElement)) {
setTempClippyIconFeedback(clippy, 'success')
} else {
setTempClippyIconFeedback(clippy, 'fail')
}
})
return clippyWrapper
}
const clippy = createClippy()
let currentCodeSnippetElement: Element
function attachCopySnippet() {
export function attachCopySnippet() {
const readmeSelector = '.repository-content div#readme'
return $(readmeSelector, () => {
const readmeArticleSelector = '.repository-content div#readme article'
$(
return $(
readmeArticleSelector,
readmeElement =>
readmeElement.addEventListener('mouseover', e => {
// only move clippy when mouse is over a new snippet(<pre>)
const target = e.target as Element
if (target.nodeName === 'PRE') {
if (currentCodeSnippetElement !== target) {
currentCodeSnippetElement = target
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 -->
@ -322,10 +262,26 @@ function attachCopySnippet() {
* </div>
* </article>
*/
if (target.parentNode) target.parentNode.insertBefore(clippy, target)
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, () =>
@ -341,14 +297,14 @@ function attachCopySnippet() {
/**
* focus to side bar, user will be able to manipulate it with keyboard
*/
function focusFileExplorer() {
export function focusFileExplorer() {
const sideBarContentSelector = '.gitako-side-bar .file-explorer'
$(sideBarContentSelector, sideBarElement => {
if (sideBarElement instanceof HTMLElement) sideBarElement.focus()
})
}
function focusSearchInput() {
export function focusSearchInput() {
const searchInputSelector = '.search-input'
$(searchInputSelector, searchInputElement => {
if (
@ -360,44 +316,10 @@ function focusSearchInput() {
})
}
/**
* a combination of few above functions
*/
function decorateGitHubPageContent({
copyFileButton,
copySnippetButton,
}: {
copyFileButton: boolean
copySnippetButton: boolean
}) {
if (copyFileButton) attachCopyFileBtn()
if (copySnippetButton) attachCopySnippet()
}
function mountTopProgressBar() {
export function mountTopProgressBar() {
NProgress.start()
}
function unmountTopProgressBar() {
export function unmountTopProgressBar() {
NProgress.done()
}
export default {
loadWithPJAX,
attachCopyFileBtn,
attachCopySnippet,
decorateGitHubPageContent,
focusSearchInput,
focusFileExplorer,
getCurrentPageType,
getRepoPageType,
insertLogoMountPoint,
markGitakoReadyState,
setBodyIndent,
scrollToRepoContent,
mountTopProgressBar,
unmountTopProgressBar,
isInCodePage,
getBranches,
getCurrentBranch,
}

View file

@ -70,7 +70,11 @@ type RepoMetaData = {
}
}
async function getRepoMeta({ userName, repoName, accessToken }: MetaData): Promise<RepoMetaData> {
export async function getRepoMeta({
userName,
repoName,
accessToken,
}: MetaData): Promise<RepoMetaData> {
const url = `https://api.github.com/repos/${userName}/${repoName}`
return await request(url, { accessToken })
}
@ -91,7 +95,7 @@ export type TreeData = {
url: string
}
async function getTreeData({
export async function getTreeData({
userName,
repoName,
branchName,
@ -109,7 +113,7 @@ export type BlobData = {
url: string
}
async function getBlobData({
export async function getBlobData({
userName,
repoName,
accessToken,
@ -121,17 +125,10 @@ async function getBlobData({
return await request(url, { accessToken })
}
function getUrlForRedirect(
export function getUrlForRedirect(
{ userName, repoName, branchName }: MetaData,
type = 'blob',
path?: string,
) {
return `https://github.com/${userName}/${repoName}/${type}/${branchName}/${path}`
}
export default {
getRepoMeta,
getTreeData,
getBlobData,
getUrlForRedirect,
}

View file

@ -1,7 +1,7 @@
import { raiseError } from 'analytics'
import { MetaData } from './GitHubHelper'
function parse(): MetaData & { path: string[] } {
export function parse(): MetaData & { path: string[] } {
const { pathname } = window.location
let [
,
@ -19,12 +19,12 @@ function parse(): MetaData & { path: string[] } {
}
}
function parseSHA() {
export function parseSHA() {
const { type, path } = parse()
return type === 'blob' || type === 'tree' ? path[0] : undefined
}
function isInRepoPage() {
export function isInRepoPage() {
const repoHeaderSelector = '.repohead'
return Boolean(document.querySelector(repoHeaderSelector))
}
@ -38,7 +38,7 @@ const TYPES = {
// TODO: record more types
}
function isInCodePage(metaData: MetaData = {}) {
export function isInCodePage(metaData: MetaData = {}) {
const mergedRepo = { ...parse(), ...metaData }
const { type, branchName } = mergedRepo
return Boolean(
@ -57,7 +57,7 @@ function isCompleteCommitSHA(sha?: string) {
return typeof sha === 'string' && /^[abcdef0-9]{40}$/i.test(sha)
}
function getCurrentPath(branchName = '') {
export function getCurrentPath(branchName = '') {
const { path, type } = parse()
if (type === 'blob' || type === 'tree') {
if (isCommitPath(path)) {
@ -90,11 +90,3 @@ function getCurrentPath(branchName = '') {
}
return []
}
export default {
getCurrentPath,
isInRepoPage,
isInCodePage,
parse,
parseSHA,
}

View file

@ -1,4 +1,4 @@
import storageHelper from 'utils/storageHelper'
import * as storageHelper from 'utils/storageHelper'
export type Config = {
sideBarWidth: number
@ -20,7 +20,7 @@ export enum configKeys {
intelligentToggle = 'intelligentToggle',
}
const defaultConfigs: Config = {
export const defaultConfigs: Config = {
sideBarWidth: 260,
shortcut: undefined,
access_token: undefined,
@ -43,27 +43,10 @@ function applyDefaultConfigs(configs: Config) {
)
}
async function getAll(): Promise<Config> {
export async function get(): Promise<Config> {
return applyDefaultConfigs(await storageHelper.get(configKeyArray))
}
async function getOne(key: configKeys) {
return (await getAll())[key]
}
async function setAll(partialConfig: Partial<Config>) {
export async function set(partialConfig: Partial<Config>) {
return await storageHelper.set(partialConfig)
}
async function setOne(key: configKeys, value: any) {
return await setAll({
[key]: value,
})
}
export default {
getAll,
getOne,
setAll,
setOne,
}

View file

@ -1,7 +1,7 @@
/**
* cx('class1', { class2: true, class3: false }) --> 'class1 class2'
*/
export default function cx(...classNames: any[]): string {
export function cx(...classNames: any[]): string {
return classNames
.filter(Boolean)
.map(className => {

View file

@ -1,16 +1,15 @@
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') {
return (Array.isArray(keys) ? keys : Object.keys(keys)).reduce(
(copy, key) => {
if (key in source) {
copy[key as keyof T] = source[key as keyof T]
}
return copy
},
{} as Partial<T>,
)
return (Array.isArray(keys) ? keys : Object.keys(keys)).reduce((copy, key) => {
if (key in source) {
copy[key as keyof T] = source[key as keyof T]
}
return copy
}, {} as Partial<T>)
}
return {} as Partial<T>
}
@ -114,20 +113,22 @@ export function parseURLSearch(search: string = window.location.search) {
}
export async function JSONRequest(url: string, data: any, extra: RequestInit = { method: 'post' }) {
return (await fetch(url, {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
method: extra.method || 'post',
body: JSON.stringify(data),
...extra,
})).json()
return (
await fetch(url, {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
method: extra.method || 'post',
body: JSON.stringify(data),
...extra,
})
).json()
}
export function searchKeyToRegexps(searchKey: string) {
@ -141,3 +142,12 @@ export function searchKeyToRegexps(searchKey: string) {
return [/$^/] // matching nothing if failed transforming regexp
}
}
export async function renderReact(element: ReactElement) {
return new Promise<Node>(resolve => {
const mount = document.createElement('div')
ReactDOM.render(element, mount, () => {
resolve(mount.childNodes[0])
})
})
}

View file

@ -47,3 +47,35 @@ export function usePrevious<T>(newValue: T) {
})
return previousRef.current
}
export function useStates<S>(
initialState: S | (() => S),
): { val: S; set: React.Dispatch<React.SetStateAction<S>> } {
const [val, set] = React.useState(initialState)
return { val, set }
}
export function useAsyncMemo<T, D extends any[] | readonly any[]>(
factory: (dependencies: D) => T | Promise<T>,
deps: D,
initialValue: T,
): T {
const firstTime = React.useRef(true)
const state = useStates<T>(() => initialValue)
React.useEffect(() => {
if (firstTime.current) firstTime.current = false
Promise.resolve(factory(deps)).then(consumed => state.set(() => consumed))
}, deps)
return state.val
}
export function useDidUpdate(effect: React.EffectCallback, deps?: React.DependencyList) {
const firstTime = React.useRef(true)
React.useEffect(() => {
if (firstTime.current) {
firstTime.current = false
return
}
return effect()
}, deps)
}

View file

@ -36,7 +36,7 @@ function parseKeyCode(code: string) {
return code.toLowerCase().replace(/^control$/, 'ctrl')
}
function parseEvent(e: KeyboardEvent | React.KeyboardEvent) {
export function parseEvent(e: KeyboardEvent | React.KeyboardEvent) {
const { altKey: alt, shiftKey: shift, metaKey: meta, ctrlKey: ctrl } = e
try {
const code = parseKeyCode(e.key)
@ -57,7 +57,3 @@ function parseEvent(e: KeyboardEvent | React.KeyboardEvent) {
throw new Error(`Error parse keyboard event: ${serializedKeyData}`)
}
}
export default {
parseEvent,
}

View file

@ -1,14 +1,9 @@
const localStorage = browser.storage.local
function get(mapping: string[] | null): Promise<any> {
export function get(mapping: string[] | null): Promise<any> {
return localStorage.get(mapping || undefined)
}
function set(value: any): Promise<void> {
export function set(value: any): Promise<void> {
return localStorage.set(value)
}
export default {
get,
set,
}

View file

@ -1,4 +1,4 @@
import GitHubHelper, { MetaData, TreeData } from 'utils/GitHubHelper'
import { getUrlForRedirect, MetaData, TreeData } from 'utils/GitHubHelper'
import { TreeNode } from './VisibleNodesGenerator'
interface RawItem {
@ -36,7 +36,7 @@ function findGitModules(root: TreeNode) {
return null
}
function parse(treeData: TreeData, metaData: MetaData) {
export function parse(treeData: TreeData, metaData: MetaData) {
const { tree } = treeData
// nodes are created from items and put onto tree
@ -70,7 +70,7 @@ function parse(treeData: TreeData, metaData: MetaData) {
name: item.path && item.path.replace(/^.*\//, ''),
url:
item.url && item.type && item.path
? GitHubHelper.getUrlForRedirect(metaData, item.type, item.path)
? getUrlForRedirect(metaData, item.type, item.path)
: null,
contents: item.type === 'tree' ? [] : null,
} as TreeNode
@ -88,7 +88,3 @@ function parse(treeData: TreeData, metaData: MetaData) {
root: sortFoldersToFront(root),
}
}
export default {
parse,
}

View file

@ -202,7 +202,7 @@ type Options = {
compress?: boolean
}
export default class VisibleNodesGenerator {
export class VisibleNodesGenerator {
l1: L1
l2: L2
l3: L3

View file

@ -1,5 +1,4 @@
{
"files": ["src/content.tsx"],
"compilerOptions": {
"target": "es2016",
"outDir": "dist",

View file

@ -38,15 +38,12 @@ if (analyse) {
}
const IN_PRODUCTION_MODE = process.env.NODE_ENV === 'production'
if (IN_PRODUCTION_MODE) {
plugins.push(
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production'),
},
}),
)
}
plugins.push(
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.VERSION': JSON.stringify(process.env.VERSION),
}),
)
module.exports = {
entry: {

View file

@ -304,6 +304,14 @@
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-optional-catch-binding" "^7.2.0"
"@babel/plugin-proposal-optional-chaining@^7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.6.0.tgz#e9bf1f9b9ba10c77c033082da75f068389041af8"
integrity sha512-kj4gkZ6qUggkprRq3Uh5KP8XnE1MdIO0J7MhdDX8+rAbB6dJ2UrensGIS+0NPZAaaJ1Vr0PN6oLUgXMU1uMcSg==
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-optional-chaining" "^7.2.0"
"@babel/plugin-proposal-unicode-property-regex@^7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.4.4.tgz#501ffd9826c0b91da22690720722ac7cb1ca9c78"
@ -355,6 +363,13 @@
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-optional-chaining@^7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.2.0.tgz#a59d6ae8c167e7608eaa443fda9fa8fa6bf21dff"
integrity sha512-HtGCtvp5Uq/jH/WNUPkK6b7rufnCPLLlDAFN7cmACoIjaOOiXxUt3SswU5loHqrhtqTsa/WoLQ1OQ1AGuZqaWA==
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-typescript@^7.2.0":
version "7.3.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.3.3.tgz#a7cc3f66119a9f7ebe2de5383cce193473d65991"