mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
Merge branch 'feature/advance' into develop
This commit is contained in:
commit
6e18f3c999
48 changed files with 1212 additions and 1266 deletions
5
.babelrc
5
.babelrc
|
|
@ -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
21
Makefile
Executable 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
36
src/components/Clippy.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/components/CopyFileButton.tsx
Normal file
42
src/components/CopyFileButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
src/components/ErrorBoundary.tsx
Normal file
12
src/components/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'}>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
52
src/components/MoreOption.tsx
Normal file
52
src/components/MoreOption.tsx
Normal 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)}
|
||||
/>
|
||||
{field.label}
|
||||
{field.wikiLink ? (
|
||||
<a href={field.wikiLink} target={'_blank'}>
|
||||
(?)
|
||||
</a>
|
||||
) : (
|
||||
field.description && (
|
||||
<span className={'description'} title={field.description}>
|
||||
(?)
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
(?)
|
||||
</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()}
|
||||
/>
|
||||
{option.label}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
41
src/containers/ConfigsContext.tsx
Normal file
41
src/containers/ConfigsContext.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
9
src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
type ValSet<T> = {
|
||||
val: T
|
||||
set: (val: T) => void
|
||||
}
|
||||
|
||||
type PartialValSet<T> = {
|
||||
val: T
|
||||
set: (val: Partial<T>) => void
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ type Options = {
|
|||
compress?: boolean
|
||||
}
|
||||
|
||||
export default class VisibleNodesGenerator {
|
||||
export class VisibleNodesGenerator {
|
||||
l1: L1
|
||||
l2: L2
|
||||
l3: L3
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"files": ["src/content.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"outDir": "dist",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
15
yarn.lock
15
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue