diff --git a/.babelrc b/.babelrc index e952314..4d21215 100644 --- a/.babelrc +++ b/.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" ] diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..a9c4084 --- /dev/null +++ b/Makefile @@ -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 diff --git a/package.json b/package.json index f095f8a..d5a4d62 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index bff0774..0000000 --- a/scripts/release.sh +++ /dev/null @@ -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 diff --git a/src/analytics.ts b/src/analytics.ts index 492c2f2..0fdf838 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -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 diff --git a/src/components/Clippy.tsx b/src/components/Clippy.tsx new file mode 100644 index 0000000..86ba87f --- /dev/null +++ b/src/components/Clippy.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/src/components/CopyFileButton.tsx b/src/components/CopyFileButton.tsx new file mode 100644 index 0000000..dba3066 --- /dev/null +++ b/src/components/CopyFileButton.tsx @@ -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) { + 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 ( + { + const codeElement = getCodeElement() + if (codeElement) { + if (copyElementContent(codeElement)) { + setContent(contents.success) + } else { + setContent(contents.error) + } + } + }} + > + {content} + + ) +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..0f8c74e --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -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 + } +} diff --git a/src/components/FileExplorer.tsx b/src/components/FileExplorer.tsx index 0caa9ec..a2a9020 100644 --- a/src/components/FileExplorer.tsx +++ b/src/components/FileExplorer.tsx @@ -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(null) -class FileExplorer extends React.Component { - static defaultProps: Partial = { - freeze: false, - searchKey: '', - visibleNodes: null, - } +const RawFileExplorer: React.FC = 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 - } - return ( - - {({ width = 0, height = 0 }) => ( - - )} - - ) - } - - ListV = React.memo<{ - nodes: TreeNode[] - height: number - width: number - focusedNode: TreeNode | null - }>(({ nodes, width, height, focusedNode }) => { - const listRef = React.useRef(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 ( - { - const node = nodes[index] - return node && node.path - }} - itemData={{ nodes }} - itemCount={nodes.length} - itemSize={35} - height={height} - width={width} - > - {this.VirtualNode} - - ) }) - VirtualNode = React.memo(({ index, style }) => { - const { visibleNodes, onNodeClick } = this.props - if (!visibleNodes) return null - const { nodes, depths, focusedNode, expandedNodes } = visibleNodes - const node = nodes[index] - return ( - - ) - }) - - private renderActions: Node['props']['renderActions'] = node => { - const { searchKey, goTo } = this.props - return ( - searchKey && ( + const renderActions: React.ComponentProps['renderActions'] = React.useCallback( + node => + searchKey ? ( - ) - ) - } + ) : null, + [searchKey, props.goTo], + ) - revealNode( + const renderNode = React.useCallback( + ({ index, style }: ListChildComponentProps) => ( + + ), + [renderActions, onNodeClick], + ) + + const renderFiles = React.useCallback( + ({ nodes, focusedNode }: VisibleNodes) => { + const inSearch = searchKey !== '' + if (inSearch && nodes.length === 0) { + return + } + return ( + + {({ width = 0, height = 0 }) => ( + + )} + + ) + }, + [searchKey, ListView, renderNode], + ) + + const revealNode = React.useCallback(function revealNode( goTo: (path: string[]) => void, node: TreeNode, ): (event: React.MouseEvent) => void { @@ -148,39 +94,113 @@ class FileExplorer extends React.Component { e.preventDefault() goTo(node.path.split('/')) } - } + }, + []) - render() { - const { - stateText, - visibleNodes, - freeze, - handleKeyDown, - search, - toggleShowSettings, - onFocusSearchBar, - searchKey, - } = this.props - return ( + return ( +
- {stateText ? ( - + {props.stateText ? ( + ) : ( visibleNodes && ( - - - {this.renderFiles(visibleNodes)} - + <> + + {renderFiles(visibleNodes)} + ) )}
- ) - } +
+ ) } -export default connect(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 ( + + ) +} + +function ListView({ + nodes, + width, + height, + focusedNode, + renderNode, +}: { + nodes: TreeNode[] + height: number + width: number + focusedNode: TreeNode | null + renderNode: ListProps['children'] +}) { + const listRef = React.useRef(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 ( + { + const node = nodes[index] + return node && node.path + }} + itemData={{ nodes }} + itemCount={nodes.length} + itemSize={35} + height={height} + width={width} + > + {renderNode} + + ) +} diff --git a/src/components/Gitako.tsx b/src/components/Gitako.tsx index 5a889b2..7c68258 100644 --- a/src/components/Gitako.tsx +++ b/src/components/Gitako.tsx @@ -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 - } +export function Gitako() { + return ( + + + + {configContext => configContext && } + + + + ) } diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index a128ed5..5ffd0e4 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -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) => void } -const Icon: React.SFC = 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 = function Icon({ type, className = undefined, ...o ) } - -export default Icon diff --git a/src/components/LoadingIndicator.tsx b/src/components/LoadingIndicator.tsx index 20e8f5a..86bc16f 100644 --- a/src/components/LoadingIndicator.tsx +++ b/src/components/LoadingIndicator.tsx @@ -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 (
diff --git a/src/components/MetaBar.tsx b/src/components/MetaBar.tsx index 7175272..2808240 100644 --- a/src/components/MetaBar.tsx +++ b/src/components/MetaBar.tsx @@ -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 ( diff --git a/src/components/MoreOption.tsx b/src/components/MoreOption.tsx new file mode 100644 index 0000000..65a9d66 --- /dev/null +++ b/src/components/MoreOption.tsx @@ -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: (value: T) => boolean + onChange: (checked: boolean) => any + } +} + +export function SimpleFieldInput({ field, overwrite, onChange }: Props) { + const configContext = useConfigs() + const value = configContext.val[field.key] + return ( + + ) +} diff --git a/src/components/Node.tsx b/src/components/Node.tsx index 4641c50..943e9bc 100644 --- a/src/components/Node.tsx +++ b/src/components/Node.tsx @@ -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 { - 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 ( -
- - + ) } diff --git a/src/components/PJAXLink.tsx b/src/components/PJAXLink.tsx index bc0e7d4..680214e 100644 --- a/src/components/PJAXLink.tsx +++ b/src/components/PJAXLink.tsx @@ -1,12 +1,12 @@ import * as React from 'react' -import DOMHelper from 'utils/DOMHelper' +import * as DOMHelper from 'utils/DOMHelper' type Props

= { to: string children: React.ReactElement

} -export default function PJAXLink

({ to, children }: Props

) { +export function PJAXLink

({ to, children }: Props

) { return React.cloneElement(children, { ...children.props, onClick: () => DOMHelper.loadWithPJAX(to), diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx index 04ebd72..2497954 100644 --- a/src/components/Portal.tsx +++ b/src/components/Portal.tsx @@ -5,12 +5,8 @@ type Props = { into: Element | null } -class Portal extends React.PureComponent { - render() { - const { into, children } = this.props - if (!(into instanceof Element)) return null - return ReactDOM.createPortal(children, into) - } +export function Portal(props: React.PropsWithChildren) { + const { into, children } = props + if (!(into instanceof Element)) return null + return ReactDOM.createPortal(children, into) } - -export default Portal diff --git a/src/components/Resizable.tsx b/src/components/Resizable.tsx index 39133fb..8a8fc0d 100644 --- a/src/components/Resizable.tsx +++ b/src/components/Resizable.tsx @@ -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) { +export function Resizable({ baseSize, className, children }: React.PropsWithChildren) { 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( diff --git a/src/components/ResizeHandler.tsx b/src/components/ResizeHandler.tsx index 9886d13..a3b9fd8 100644 --- a/src/components/ResizeHandler.tsx +++ b/src/components/ResizeHandler.tsx @@ -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 { - 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 ( -

- -
- ) - } + return ( +
+ +
+ ) } diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 252b63a..f69895d 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -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 (
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) => Promise | void - getValue: () => boolean - wikiLink?: string - description?: string - }[] } -export default class SettingsBar extends React.PureComponent { - 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) => { - 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('') + const useShortcutHint = useStates('') + const useToggleShowSideBarShortcut = useStates(configContext.val.shortcut) + const useReloadHint = useStates('') - 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) => { + 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 || ( + + window.location.reload()}> + Reload + {' '} + to activate! + + ), + ) } - } catch (err) { - raiseError(err) - } - } + }, + [accessToken], + ) - onInputAccessToken = (event: React.FormEvent) => { - 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'] = ( - - window.location.reload()}> - Reload - {' '} - to activate! - - ), - ) => { - 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) => { + const onShortCutInputKeyDown = React.useCallback((e: React.KeyboardEvent) => { 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: ( - - Saved,{' '} - window.location.reload()}> - reload - {' '} - to apply. - - ), - }) - } - - createOnToggleChecked( - configKey: configKeys, - set: (value: boolean) => void, - ): (e: React.FormEvent) => Promise { - 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 ( -
- {activated && ( - -

Settings

-
-
-
-

- Access Token - -  (?) - -

- {!hasAccessToken && ( - { - // 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) - - )} -
- - {hasAccessToken && !accessToken ? ( - - ) : ( - - )} -
- {accessTokenHint && {accessTokenHint}} -
-
-

Toggle Shortcut

- Set a combination of keys for toggling Gitako sidebar. -
-
- - -
- {shortcutHint && {shortcutHint}} -
-
-

More Options

- {varyOptions.map(option => ( - - -
-
- ))} - {reloadHint &&
{reloadHint}
} -
- -
- - )} -
- - v{version} - - {activated ? ( - - ) : ( - + return ( + <> +

Settings

+
+
+
+

+ Access Token{' '} + + (?) + +

+ {!hasAccessToken && ( + { + // 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) + )} +
+ + {hasAccessToken && !accessToken ? ( + + ) : ( + + )} +
+ {accessTokenHint && {accessTokenHint}} +
+
+

Toggle Shortcut

+ Set a combination of keys for toggling Gitako sidebar. +
+
+ + +
+ {shortcutHint && {shortcutHint}} +
+
+

More Options

+ {moreFields.map(field => ( + + +
+
+ ))} + + {reloadHint &&
{reloadHint}
} +
+
- ) - } + + ) +} + +export function SettingsBar(props: Props) { + const { toggleShowSettings, activated } = props + return ( +
+ {activated && } +
+ + {VERSION} + + {activated ? ( + + ) : ( + + )} +
+
+ ) } diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 781ac8d..82bdbdc 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -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 = function RawGitako(props) { + const configContext = useConfigs() + const accessToken = props.configContext.val.access_token -class Gitako extends React.PureComponent { - static defaultProps: Partial = { - 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 ( -
-
Access Denied
-

- Due to{' '} - - limitation of GitHub - {' '} - or{' '} - - auth needs - - , Gitako needs access token to continue. Please follow the instructions in the settings - panel below. -

-
- ) - } + 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 ( -
- {metaData && } - {errorDueToAuth - ? this.renderAccessDeniedError() - : metaData && ( - - )} -
- ) - } + 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 ( -
- - - - -
- {this.renderContent()} - + 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 ( +
+ + + + +
+
+ {metaData && } + {errorDueToAuth + ? renderAccessDeniedError() + : metaData && ( + + )}
- -
- ) - } + +
+ +
+ ) } -export default connect(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 ( +
+
Access Denied
+

+ Due to{' '} + + limitation of GitHub + {' '} + or{' '} + + auth needs + + , Gitako needs access token to continue. Please follow the instructions in the settings + panel below. +

+
+ ) +} + +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) + } +} diff --git a/src/components/SizeObserver.tsx b/src/components/SizeObserver.tsx index c3c443c..ca64c30 100644 --- a/src/components/SizeObserver.tsx +++ b/src/components/SizeObserver.tsx @@ -11,7 +11,7 @@ type Props = { children(size: Partial): React.ReactNode } & React.HTMLAttributes -export default function SizeObserver({ type = 'div', children, ...rest }: Props) { +export function SizeObserver({ type = 'div', children, ...rest }: Props) { const ref = React.useRef() const [size, setSize] = React.useState>({ @@ -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, - }), - ) - } } diff --git a/src/components/ToggleShowButton.tsx b/src/components/ToggleShowButton.tsx index fa45455..219b06d 100644 --- a/src/components/ToggleShowButton.tsx +++ b/src/components/ToggleShowButton.tsx @@ -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 (
+export type ConfigsContextShape = ContextShape + +export const ConfigsContext = React.createContext(null) + +export function ConfigsContextWrapper(props: React.PropsWithChildren) { + const [configs, setConfigs] = React.useState(null) + React.useEffect(() => { + configsHelper.get().then(setConfigs) + }, []) + const set = React.useCallback( + (updatedConfigs: Partial) => { + const mergedConfigs = { ...configs, ...updatedConfigs } as Config + configsHelper.set(mergedConfigs) + setConfigs(mergedConfigs) + }, + [configs, setConfigs], + ) + if (configs === null) return null + return ( + + {props.children} + + ) +} + +export const useConfigs = useNonNullContext(ConfigsContext) + +function useNonNullContext>(theContext: React.Context): () => R { + return () => { + const context = React.useContext(theContext) + if (context === null) throw new Error(`Empty context`) + return context as R + } +} diff --git a/src/content.less b/src/content.less index 1c6fdbc..b0ac42f 100644 --- a/src/content.less +++ b/src/content.less @@ -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; diff --git a/src/content.tsx b/src/content.tsx index 06b7c41..c9eb923 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -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) diff --git a/src/driver/connect.ts b/src/driver/connect.ts index 7eec449..0f31303 100644 --- a/src/driver/connect.ts +++ b/src/driver/connect.ts @@ -31,7 +31,7 @@ function run([method, args]: [M, Parameters]) { } export type DispatchState = React.Component['setState'] -export type GetState = () => State +export type GetState = () => [State, Props] export type TriggerOtherMethod = ( methodCreator: MethodCreator, ...args: Parameters>> @@ -39,7 +39,7 @@ export type TriggerOtherMethod = ( export type Dispatch = { set: DispatchState - get: GetState + get: GetState call: TriggerOtherMethod } @@ -47,7 +47,7 @@ export type MethodCreator = ( dispatch: Dispatch, ) => Method -type Sources = { +export type Sources = { [key: string]: MethodCreator } type WrappedMethods = { @@ -75,7 +75,7 @@ function link(instance: React.Component, sources: Sources): Wr const dispatchState: DispatchState = (updater, callback) => { instance.setState(updater, callback) } - const prepareState: GetState = () => instance.state + const prepareState: GetState = () => [instance.state, instance.props] const dispatch: Dispatch = { call: dispatchCall, get: prepareState, @@ -93,20 +93,20 @@ function link(instance: React.Component, sources: Sources): Wr return wrappedMethods } -export default function connect(mapping: Sources) { - return function linkComponent( - ComponentClass: React.ComponentClass, - ): React.ComponentClass { - return class AwesomeApp extends React.PureComponent { - static displayName = `Connected(${ComponentClass.displayName || ComponentClass.name})` - static defaultProps = ComponentClass.defaultProps +export function connect(mapping: Sources) { + return function linkComponent>( + Component: ComponentType, + ) { + return class ConnectedComponent extends React.PureComponent { + static displayName = `Connected(${Component.displayName || Component.name})` + static defaultProps = Component.defaultProps - state = {} as ExtraP - connectedMethods = link(this, mapping) as WrappedMethods + state: ExtraP = {} as ExtraP + connectedMethods: WrappedMethods = link(this, mapping) render() { const props = Object.assign({}, this.props, this.connectedMethods, this.state) - return React.createElement(ComponentClass, props) + return React.createElement(Component, props) } } } diff --git a/src/driver/core/FileExplorer.ts b/src/driver/core/FileExplorer.ts index ba075c5..1373e83 100644 --- a/src/driver/core/FileExplorer.ts +++ b/src/driver/core/FileExplorer.ts @@ -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 = MethodCreator -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] +export const setUpTree: BoundMethodCreator< + [Pick & Pick] > = 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, -} diff --git a/src/driver/core/SideBar.ts b/src/driver/core/SideBar.ts index d738340..f8f282d 100644 --- a/src/driver/core/SideBar.ts +++ b/src/driver/core/SideBar.ts @@ -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 | null } & { init: GetCreatedMethod - onPJAXEnd: GetCreatedMethod - onKeyDown: GetCreatedMethod + setMetaData: GetCreatedMethod + setShouldShow: GetCreatedMethod toggleShowSideBar: GetCreatedMethod toggleShowSettings: GetCreatedMethod - useListeners: GetCreatedMethod - onAccessTokenChange: GetCreatedMethod - onShortcutChange: GetCreatedMethod - setCopyFile: GetCreatedMethod - setCopySnippet: GetCreatedMethod - setCompressSingleton: GetCreatedMethod - setIntelligentToggle: GetCreatedMethod } & { baseSize: number - toggleShowSideBarShortcut?: string - accessToken?: string -} & Pick< - Config, - 'compressSingletonFolder' | 'copyFileButton' | 'copySnippetButton' | 'intelligentToggle' - > +} type BoundMethodCreator = MethodCreator -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, -} diff --git a/src/driver/core/index.ts b/src/driver/core/index.ts index 071ab89..4eb403b 100644 --- a/src/driver/core/index.ts +++ b/src/driver/core/index.ts @@ -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 = FileExplorer +export const SideBarCore: Sources = SideBar diff --git a/src/env.ts b/src/env.ts index dca85ac..bba237d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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 diff --git a/src/firefox-shim.js b/src/firefox-shim.js index 8e949c4..205efab 100644 --- a/src/firefox-shim.js +++ b/src/firefox-shim.js @@ -1 +1,3 @@ window.requestAnimationFrame = window.requestAnimationFrame.bind(window) +window.setTimeout = window.setTimeout.bind(window) +window.clearTimeout = window.clearTimeout.bind(window) diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..fac9ed3 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,9 @@ +type ValSet = { + val: T + set: (val: T) => void +} + +type PartialValSet = { + val: T + set: (val: Partial) => void +} diff --git a/src/utils/DOMHelper.ts b/src/utils/DOMHelper.ts index 31d681a..613f5c4 100644 --- a/src/utils/DOMHelper.ts +++ b/src/utils/DOMHelper.ts @@ -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 $ any, O extends () => a otherwise?: O, ): E extends never ? O extends never - ? (Element | null) + ? Element | null : ReturnType | null : O extends never - ? (ReturnType | null) + ? ReturnType | null : ReturnType | ReturnType { const element = document.querySelector(selector) if (element) { @@ -47,18 +50,18 @@ function $ 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) - } - - /** - *
- * - *
- */ - 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(
)
-          const target = e.target as Element
-          if (target.nodeName === 'PRE') {
-            if (currentCodeSnippetElement !== target) {
-              currentCodeSnippetElement = target
+      readmeElement => {
+        const mouseOverCallback = async ({ target }: Event): Promise => {
+          if (target instanceof Element && target.nodeName === 'PRE') {
+            if (
+              target.previousSibling === null ||
+              !(target.previousSibling instanceof Element) ||
+              !target.previousSibling.classList.contains(ClippyClassName)
+            ) {
               /**
                *  
*
     
@@ -322,10 +262,26 @@ function attachCopySnippet() {
                *    
* */ - 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, -} diff --git a/src/utils/GitHubHelper.ts b/src/utils/GitHubHelper.ts index 42cbf54..140e8b8 100644 --- a/src/utils/GitHubHelper.ts +++ b/src/utils/GitHubHelper.ts @@ -70,7 +70,11 @@ type RepoMetaData = { } } -async function getRepoMeta({ userName, repoName, accessToken }: MetaData): Promise { +export async function getRepoMeta({ + userName, + repoName, + accessToken, +}: MetaData): Promise { 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, -} diff --git a/src/utils/URLHelper.ts b/src/utils/URLHelper.ts index 2e2cac6..6d515bb 100644 --- a/src/utils/URLHelper.ts +++ b/src/utils/URLHelper.ts @@ -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, -} diff --git a/src/utils/configHelper.ts b/src/utils/configHelper.ts index 9a84d91..470a005 100644 --- a/src/utils/configHelper.ts +++ b/src/utils/configHelper.ts @@ -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 { +export async function get(): Promise { return applyDefaultConfigs(await storageHelper.get(configKeyArray)) } -async function getOne(key: configKeys) { - return (await getAll())[key] -} - -async function setAll(partialConfig: Partial) { +export async function set(partialConfig: Partial) { return await storageHelper.set(partialConfig) } - -async function setOne(key: configKeys, value: any) { - return await setAll({ - [key]: value, - }) -} - -export default { - getAll, - getOne, - setAll, - setOne, -} diff --git a/src/utils/cx.ts b/src/utils/cx.ts index be8031f..179b6d3 100644 --- a/src/utils/cx.ts +++ b/src/utils/cx.ts @@ -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 => { diff --git a/src/utils/general.ts b/src/utils/general.ts index a642357..0b4e218 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -1,16 +1,15 @@ +import { ReactElement } from 'react' +import * as ReactDOM from 'react-dom' import { TreeNode } from './VisibleNodesGenerator' export function pick(source: T, keys: string[]): Partial { if (keys && typeof keys === 'object') { - 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, - ) + 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) } return {} as Partial } @@ -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(resolve => { + const mount = document.createElement('div') + ReactDOM.render(element, mount, () => { + resolve(mount.childNodes[0]) + }) + }) +} diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 578ae1f..fe55030 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -47,3 +47,35 @@ export function usePrevious(newValue: T) { }) return previousRef.current } + +export function useStates( + initialState: S | (() => S), +): { val: S; set: React.Dispatch> } { + const [val, set] = React.useState(initialState) + return { val, set } +} + +export function useAsyncMemo( + factory: (dependencies: D) => T | Promise, + deps: D, + initialValue: T, +): T { + const firstTime = React.useRef(true) + const state = useStates(() => 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) +} diff --git a/src/utils/keyHelper.ts b/src/utils/keyHelper.ts index a06850c..3394516 100644 --- a/src/utils/keyHelper.ts +++ b/src/utils/keyHelper.ts @@ -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, -} diff --git a/src/utils/storageHelper.ts b/src/utils/storageHelper.ts index 6f62d20..8c52640 100644 --- a/src/utils/storageHelper.ts +++ b/src/utils/storageHelper.ts @@ -1,14 +1,9 @@ const localStorage = browser.storage.local -function get(mapping: string[] | null): Promise { +export function get(mapping: string[] | null): Promise { return localStorage.get(mapping || undefined) } -function set(value: any): Promise { +export function set(value: any): Promise { return localStorage.set(value) } - -export default { - get, - set, -} diff --git a/src/utils/treeParser.ts b/src/utils/treeParser.ts index 0e64998..db9dd8d 100644 --- a/src/utils/treeParser.ts +++ b/src/utils/treeParser.ts @@ -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, -} diff --git a/src/utils/visibleNodesGenerator.ts b/src/utils/visibleNodesGenerator.ts index 5212889..bc8dc9e 100644 --- a/src/utils/visibleNodesGenerator.ts +++ b/src/utils/visibleNodesGenerator.ts @@ -202,7 +202,7 @@ type Options = { compress?: boolean } -export default class VisibleNodesGenerator { +export class VisibleNodesGenerator { l1: L1 l2: L2 l3: L3 diff --git a/tsconfig.json b/tsconfig.json index e181701..08918c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,4 @@ { - "files": ["src/content.tsx"], "compilerOptions": { "target": "es2016", "outDir": "dist", diff --git a/webpack.config.js b/webpack.config.js index 02c11e1..a3d34fd 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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: { diff --git a/yarn.lock b/yarn.lock index 4ab28d4..6f77807 100644 --- a/yarn.lock +++ b/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"