changes are out of control

This commit is contained in:
EnixCoda 2019-11-11 23:56:20 +08:00
parent f677d7df5a
commit ddb86fa4ef
No known key found for this signature in database
GPG key ID: 0C1A07377913A1DD
11 changed files with 569 additions and 633 deletions

View file

@ -1,129 +1,91 @@
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 { 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'
class RawFileExplorer extends React.Component<Props & ConnectorState> {
static defaultProps: Partial<Props & ConnectorState> = {
freeze: false,
searchKey: '',
visibleNodes: null,
}
const VisibleNodesContext = React.createContext<VisibleNodes | null>(null)
componentDidMount() {
const { init, setUpTree, treeData, metaData, compressSingletonFolder, accessToken } = this.props
const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplorer(props) {
const { visibleNodes, freeze, onNodeClick, searchKey } = props
const {
val: { access_token: accessToken, compressSingletonFolder },
} = useConfigs()
React.useEffect(() => {
const { init } = props
init()
}, [])
React.useEffect(() => {
const { setUpTree, treeData, metaData } = props
setUpTree({ treeData, metaData, compressSingletonFolder, accessToken })
const { execAfterRender } = this.props
}, [props.setUpTree, props.treeData, props.metaData, compressSingletonFolder, accessToken])
React.useEffect(() => {
const { execAfterRender } = props
execAfterRender()
}
componentDidUpdate(prevProps: Props & ConnectorState) {
if (this.props.treeData !== prevProps.treeData) {
const { setUpTree, treeData, metaData, compressSingletonFolder, accessToken } = this.props
setUpTree({ treeData, metaData, compressSingletonFolder, accessToken })
}
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: React.ComponentProps<typeof Node>['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 {
@ -132,39 +94,113 @@ class RawFileExplorer 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>
)
}
RawFileExplorer.defaultProps = {
freeze: false,
searchKey: '',
visibleNodes: null,
}
export const FileExplorer = connect(FileExplorerCore)(RawFileExplorer)
function VirtualNode({
index,
style,
onNodeClick,
renderActions,
}: {
index: number
style: React.CSSProperties
onNodeClick: (treeNode: TreeNode) => void
renderActions: ((node: TreeNode) => React.ReactNode) | undefined
}) {
const visibleNodes = React.useContext(VisibleNodesContext)
if (!visibleNodes) return null
const { nodes, depths, focusedNode, expandedNodes } = visibleNodes
const node = nodes[index]
return (
<Node
style={style}
key={node.path}
node={node}
depth={depths.get(node) || 0}
focused={focusedNode === node}
expanded={expandedNodes.has(node)}
onClick={onNodeClick}
renderActions={renderActions}
/>
)
}
function ListView({
nodes,
width,
height,
focusedNode,
renderNode,
}: {
nodes: TreeNode[]
height: number
width: number
focusedNode: TreeNode | null
renderNode: ListProps['children']
}) {
const listRef = React.useRef<List>(null)
React.useEffect(() => {
if (focusedNode && listRef.current) {
listRef.current.scrollToItem(nodes.indexOf(focusedNode), 'smart')
}
}, [listRef.current, focusedNode])
const lastNodeLength = usePrevious(nodes.length)
React.useEffect(() => {
if (listRef.current && !focusedNode && lastNodeLength !== nodes.length) {
listRef.current.scrollTo(0)
}
}, [listRef.current, focusedNode, nodes.length])
return (
<List
ref={listRef}
itemKey={(index, { nodes }) => {
const node = nodes[index]
return node && node.path
}}
itemData={{ nodes }}
itemCount={nodes.length}
itemSize={35}
height={height}
width={width}
>
{renderNode}
</List>
)
}

View file

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

View file

@ -1,10 +1,12 @@
import { raiseError } from 'analytics'
import { Icon } from 'components/Icon'
import { useConfigs } from 'containers/ConfigsContext'
import { oauth, VERSION } from 'env'
import * as React from 'react'
import { Config, configKeys, setOne } from 'utils/configHelper'
import { friendlyFormatShortcut, JSONRequest, parseURLSearch } 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 = {
@ -18,348 +20,268 @@ 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 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 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>('')
componentDidUpdate({ toggleShowSideBarShortcut }: Props) {
if (toggleShowSideBarShortcut !== this.props.toggleShowSideBarShortcut) {
this.setState({ toggleShowSideBarShortcut: this.props.toggleShowSideBarShortcut })
React.useEffect(() => {
if (!configContext.val.access_token) {
trySetUpAccessTokenWithCode().then(accessToken => {
useAccessToken.set(accessToken)
saveToken('')
})
}
}
}, [])
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(''))
}
} catch (err) {
raiseError(err)
}
}
React.useEffect(() => {
useToggleShowSideBarShortcut.set(configContext.val.shortcut)
}, [configContext.val.shortcut])
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.',
})
}
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.',
)
},
[],
)
onPressAccessToken = (event: React.KeyboardEvent) => {
const { key } = event
if (key === 'Enter') {
this.saveToken()
}
}
const onPressAccessToken = React.useCallback(({ key }: React.KeyboardEvent) => {
if (key === 'Enter') 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
const saveToken = React.useCallback(async (hint?: typeof useAccessTokenHint.val) => {
const { val: accessToken } = useAccessToken
if (accessToken) {
await setOne(configKeys.accessToken, accessToken)
onAccessTokenChange(accessToken)
this.setState({
accessToken: '',
accessTokenHint: hint,
})
configContext.set({ access_token: accessToken })
useAccessToken.set('')
useAccessTokenHint.set(
hint || (
<span>
<a href="#" onClick={() => window.location.reload()}>
Reload
</a>{' '}
to activate!
</span>
),
)
}
}
}, [])
clearToken = async () => {
const { onAccessTokenChange } = this.props
await setOne(configKeys.accessToken, '')
onAccessTokenChange('')
this.setState({ accessToken: '' })
}
const clearToken = React.useCallback(async () => {
configContext.set({ access_token: '' })
useAccessToken.set('')
}, [])
saveShortcut = async () => {
const { onShortcutChange } = this.props
const { toggleShowSideBarShortcut } = this.state
await 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: (
const showReloadHint = React.useCallback(
() =>
useReloadHint.set(
<span>
Saved,{' '}
<a href="#" onClick={() => window.location.reload()}>
reload
</a>{' '}
to apply.
</span>
</span>,
),
})
}
[],
)
createOnToggleChecked(
configKey: configKeys,
set: (value: boolean) => void,
): (e: React.FormEvent<HTMLInputElement>) => Promise<void> {
return async e => {
const enabled = e.currentTarget.checked
await setOne(configKey, enabled)
set(enabled)
this.showReloadHint()
}
}
const { val: accessTokenHint } = useAccessTokenHint
const { val: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
const { val: shortcutHint } = useShortcutHint
const { val: accessToken } = useAccessToken
const { val: reloadHint } = useReloadHint
render() {
const {
accessTokenHint,
toggleShowSideBarShortcut,
shortcutHint,
accessToken,
reloadHint,
varyOptions,
} = this.state
const { toggleShowSettings, activated } = this.props
const hasAccessToken = Boolean(this.props.accessToken)
return (
<div className={'gitako-settings-bar'}>
{activated && (
<React.Fragment>
<h3 className={'gitako-settings-bar-title'}>Settings</h3>
<div className={'gitako-settings-bar-content'}>
<div className={'shadow-shelter'} />
<div className={'gitako-settings-bar-content-section access-token'}>
<h4>
Access Token
<a href={wikiLinks.createAccessToken} target="_blank">
&nbsp;(?)
</a>
</h4>
{!hasAccessToken && (
<a
href="#"
onClick={() => {
// use js here to make sure redirect_uri is latest url
const url = `https://github.com/login/oauth/authorize?client_id=${
oauth.clientId
}&scope=repo&redirect_uri=${encodeURIComponent(window.location.href)}`
window.location.href = url
}}
>
Create with OAuth (recommended)
</a>
)}
<div className={'access-token-input-control'}>
<input
className={'access-token-input form-control'}
disabled={hasAccessToken}
placeholder={hasAccessToken ? 'Your token is saved' : 'Or input here manually'}
value={accessToken}
onChange={this.onInputAccessToken}
onKeyPress={this.onPressAccessToken}
/>
{hasAccessToken && !accessToken ? (
<button className={'btn'} onClick={this.clearToken}>
Clear
</button>
) : (
<button
className={'btn'}
onClick={() => this.saveToken()}
disabled={!accessToken}
>
Save
</button>
)}
</div>
{accessTokenHint && <span className={'hint'}>{accessTokenHint}</span>}
</div>
<div className={'gitako-settings-bar-content-section toggle-shortcut'}>
<h4>Toggle Shortcut</h4>
<span>Set a combination of keys for toggling Gitako sidebar.</span>
<br />
<div className={'toggle-shortcut-input-control'}>
<input
className={'toggle-shortcut-input form-control'}
placeholder={'focus here and press the shortcut keys'}
value={friendlyFormatShortcut(toggleShowSideBarShortcut)}
onKeyDown={this.onShortCutInputKeyDown}
readOnly
/>
<button className={'btn'} onClick={this.saveShortcut}>
Save
</button>
</div>
{shortcutHint && <span className={'hint'}>{shortcutHint}</span>}
</div>
<div className={'gitako-settings-bar-content-section others'}>
<h4>More Options</h4>
{varyOptions.map(option => (
<React.Fragment key={option.key}>
<label htmlFor={option.key}>
<input
id={option.key}
name={option.key}
type={'checkbox'}
onChange={option.onChange}
checked={option.getValue()}
/>
&nbsp;{option.label}&nbsp;
{option.wikiLink ? (
<a href={option.wikiLink} target={'_blank'}>
(?)
</a>
) : (
option.description && (
<span className={'description'} title={option.description}>
(?)
</span>
)
)}
</label>
<br />
</React.Fragment>
))}
{reloadHint && <div className={'hint'}>{reloadHint}</div>}
</div>
<div className={'gitako-settings-bar-content-section issue'}>
<h4>Contact</h4>
<a href="https://github.com/EnixCoda/Gitako/issues" target="_blank">
Bug report / feature request.
</a>
</div>
</div>
</React.Fragment>
)}
<div className={'placeholder-row'}>
<a
className={'version'}
href={wikiLinks.changeLog}
target={'_blank'}
title={'Check out new features!'}
>
{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={clearToken}>
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}
onChange={showReloadHint}
/>
<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>
)
}
async function trySetUpAccessTokenWithCode() {
try {
const search = parseURLSearch()
if ('code' in search) {
const res = await JSONRequest('https://github.com/login/oauth/access_token', {
code: search.code,
client_id: oauth.clientId,
client_secret: oauth.clientSecret,
})
const { access_token: accessToken, scope } = res
if (scope !== 'repo' || !accessToken) {
throw new Error(`Cannot resolve token response: '${JSON.stringify(res)}'`)
}
window.history.pushState({}, 'removed code', window.location.pathname.replace(/#.*$/, ''))
return accessToken
}
} catch (err) {
raiseError(err)
}
}

View file

@ -10,135 +10,92 @@ import { ConnectorState, Props } from 'driver/core/SideBar'
import * as React from 'react'
import { cx } from 'utils/cx'
class RawGitako 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,
}
componentDidMount() {
const { init, useListeners } = this.props
const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
React.useEffect(() => {
const { init, useListeners } = props
init()
useListeners(true)
}
return () => useListeners(false)
}, [])
componentWillUnmount() {
const { useListeners } = this.props
useListeners(false)
}
const accessToken = props.configContext.val.access_token
React.useEffect(() => {
if (accessToken) {
// reload when setting new accessToken
if (accessToken) props.init()
}
}, [accessToken, props.init])
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>
)
}
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>
)
}
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}
/>
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>
)
}
RawGitako.defaultProps = {
baseSize: 260,
shouldShow: false,
showSettings: false,
errorDueToAuth: false,
disabled: false,
}
export const SideBar = connect(SideBarCore)(RawGitako)
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>
)
}

View file

@ -15,11 +15,12 @@ export function ConfigsContextWrapper(props: React.PropsWithChildren<Props>) {
configsHelper.get().then(setConfigs)
}, [])
const set = React.useCallback(
() => (configs: Config) => {
configsHelper.set(configs)
setConfigs(configs)
(updatedConfigs: Partial<Config>) => {
const mergedConfigs = { ...configs, ...updatedConfigs } as Config
configsHelper.set(mergedConfigs)
setConfigs(mergedConfigs)
},
[setConfigs],
[configs, setConfigs],
)
if (configs === null) return null
return (

View file

@ -473,7 +473,7 @@
color: #6a737d;
}
}
.placeholder-row {
.header-row {
flex-shrink: 0;
display: flex;
justify-content: space-between;

View file

@ -94,10 +94,9 @@ function link<P, S>(instance: React.Component<P, S>, sources: Sources<P, S>): Wr
}
export function connect<BaseP, ExtraP>(mapping: Sources<BaseP, ExtraP>) {
return function linkComponent<
State,
ComponentClass extends React.ComponentClass<BaseP & ExtraP, State>
>(Component: ComponentClass) {
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

View file

@ -1,6 +1,7 @@
import { GetCreatedMethod, MethodCreator } from 'driver/connect'
import * as ini from 'ini'
import { Base64 } from 'js-base64'
import { Config } from 'utils/configHelper'
import * as DOMHelper from 'utils/DOMHelper'
import { findNode, searchKeyToRegexps } from 'utils/general'
import * as GitHubHelper from 'utils/GitHubHelper'
@ -13,7 +14,6 @@ export type Props = {
treeData?: GitHubHelper.TreeData
metaData: GitHubHelper.MetaData
freeze: boolean
compressSingletonFolder: boolean
accessToken: string | undefined
toggleShowSettings: React.MouseEventHandler
}
@ -137,7 +137,7 @@ function handleParsed(root: TreeNode, parsed: Parsed) {
}
export const setUpTree: BoundMethodCreator<
[Pick<Props, 'treeData' | 'metaData' | 'compressSingletonFolder' | 'accessToken'>]
[Pick<Props, 'treeData' | 'metaData' | 'accessToken'> & Pick<Config, 'compressSingletonFolder'>]
> = dispatch => async ({ treeData, metaData, compressSingletonFolder, accessToken }) => {
if (!treeData) return
dispatch.call(setStateText, 'Rendering File List...')

View file

@ -1,6 +1,5 @@
import { ConfigsContextShape } from 'containers/ConfigsContext'
import { GetCreatedMethod, MethodCreator } from 'driver/connect'
import { Config } from 'utils/configHelper'
import * as DOMHelper from 'utils/DOMHelper'
import * as GitHubHelper from 'utils/GitHubHelper'
import { MetaData, TreeData } from 'utils/GitHubHelper'
@ -34,20 +33,9 @@ export type ConnectorState = {
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>
@ -83,8 +71,6 @@ export const init: BoundMethodCreator = dispatch => async () => {
const {
sideBarWidth,
access_token: accessToken,
shortcut,
compressSingletonFolder,
copyFileButton,
copySnippetButton,
intelligentToggle,
@ -92,12 +78,6 @@ export const init: BoundMethodCreator = dispatch => async () => {
DOMHelper.decorateGitHubPageContent({ copyFileButton, copySnippetButton })
dispatch.set({
baseSize: sideBarWidth,
accessToken,
toggleShowSideBarShortcut: shortcut,
compressSingletonFolder,
copyFileButton,
copySnippetButton,
intelligentToggle,
})
if (!metaData.branchName || !metaData.userName) return
@ -178,7 +158,14 @@ export const handleError: BoundMethodCreator<[Error]> = dispatch => async err =>
}
export const onPJAXEnd: BoundMethodCreator = dispatch => () => {
const [{ metaData, copyFileButton, copySnippetButton, intelligentToggle }] = dispatch.get()
const [
{ metaData },
{
configContext: {
val: { intelligentToggle, copyFileButton, copySnippetButton },
},
},
] = dispatch.get()
DOMHelper.unmountTopProgressBar()
DOMHelper.decorateGitHubPageContent({ copyFileButton, copySnippetButton })
const mergedMetaData = { ...metaData, ...URLHelper.parse() }
@ -190,22 +177,25 @@ export const onPJAXEnd: BoundMethodCreator = dispatch => () => {
}
export const onKeyDown: BoundMethodCreator<[KeyboardEvent]> = dispatch => e => {
const [{ toggleShowSideBarShortcut }] = dispatch.get()
if (toggleShowSideBarShortcut) {
const [
,
{
configContext: {
val: { shortcut },
},
},
] = dispatch.get()
if (shortcut) {
const keys = keyHelper.parseEvent(e)
if (keys === toggleShowSideBarShortcut) {
if (keys === shortcut) {
dispatch.call(toggleShowSideBar)
}
}
}
export const toggleShowSideBar: BoundMethodCreator = dispatch => () => {
const [{ intelligentToggle, shouldShow }] = dispatch.get()
const [{ shouldShow }] = dispatch.get()
dispatch.call(setShouldShow, !shouldShow)
if (intelligentToggle !== null) {
dispatch.call(setIntelligentToggle, shouldShow)
}
}
export const setShouldShow: BoundMethodCreator<
@ -229,43 +219,9 @@ export const setShowSettings: BoundMethodCreator<
[ConnectorState['showSettings']]
> = dispatch => showSettings => dispatch.set({ showSettings })
export const onAccessTokenChange: BoundMethodCreator<
[ConnectorState['accessToken']]
> = dispatch => accessToken => {
dispatch.set({ accessToken })
// reload when setting new accessToken
if (accessToken) {
dispatch.call(init)
}
}
export const onShortcutChange: BoundMethodCreator<
[ConnectorState['toggleShowSideBarShortcut']]
> = dispatch => shortcut => dispatch.set({ toggleShowSideBarShortcut: shortcut })
export const setMetaData: BoundMethodCreator<[ConnectorState['metaData']]> = dispatch => metaData =>
dispatch.set({ metaData })
export const setCompressSingleton: BoundMethodCreator<
[ConnectorState['compressSingletonFolder']]
> = dispatch => compressSingletonFolder => dispatch.set({ compressSingletonFolder })
export const setCopyFile: BoundMethodCreator<
[ConnectorState['copyFileButton']]
> = dispatch => copyFileButton => dispatch.set({ copyFileButton })
export const setCopySnippet: BoundMethodCreator<
[ConnectorState['copySnippetButton']]
> = dispatch => copySnippetButton => dispatch.set({ copySnippetButton })
export const setIntelligentToggle: BoundMethodCreator<
[ConnectorState['intelligentToggle']]
> = dispatch => intelligentToggle => {
const [, { configContext }] = dispatch.get()
configContext.set({ intelligentToggle })
dispatch.set({ intelligentToggle })
}
export const useListeners: BoundMethodCreator<[boolean]> = dispatch => {
const $onPJAXEnd = () => dispatch.call(onPJAXEnd)
const $onKeyDown = (e: KeyboardEvent) => dispatch.call(onKeyDown, e)

View file

@ -54,3 +54,17 @@ export function useStates<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
}

View file

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