Merge branch 'refactor/simplify' into develop

This commit is contained in:
EnixCoda 2021-02-01 16:42:55 +08:00
commit fad5c268e1
No known key found for this signature in database
GPG key ID: 0C1A07377913A1DD
26 changed files with 390 additions and 362 deletions

View file

@ -32,12 +32,20 @@ const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplor
treeRoot,
defer,
} = props
const { val: config } = useConfigs()
const { value: config } = useConfigs()
React.useEffect(() => {
const { setUpTree, treeRoot, metaData } = props
setUpTree({ treeRoot, metaData, config })
}, [setUpTree, treeRoot, config.compressSingletonFolder, config.accessToken])
if (treeRoot) {
setUpTree({
treeRoot,
metaData,
config: {
compressSingletonFolder: config.compressSingletonFolder,
accessToken: config.accessToken,
},
})
}
}, [setUpTree, treeRoot, metaData, config.compressSingletonFolder, config.accessToken])
React.useEffect(() => {
if (visibleNodes?.focusedNode) focusFileExplorer()

View file

@ -87,7 +87,7 @@ const NodeItemIcon = React.memo(function NodeItemIcon({
loading?: boolean
}) {
const {
val: { icons },
value: { icons },
} = useConfigs()
const src = React.useMemo(

View file

@ -38,7 +38,7 @@ export function Resizable({ baseSize, className, children }: React.PropsWithChil
}, [width, size])
useCSSVariable('--gitako-width', `${size}px`)
useDebounce(() => configContext.set({ sideBarWidth: size }), 100, [size])
useDebounce(() => configContext.onChange({ sideBarWidth: size }), 100, [size])
const onResize = React.useCallback((size: number) => {
// do NOT merge this with the above similar effect, side bar will jump otherwise

View file

@ -5,7 +5,6 @@ import { Portal } from 'components/Portal'
import { Resizable } from 'components/Resizable'
import { SettingsBar } from 'components/settings/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'
@ -16,9 +15,8 @@ import {
useGitHubAttachCopySnippetButton,
} from 'platforms/GitHub'
import * as React from 'react'
import { useUpdateEffect } from 'react-use'
import { cx } from 'utils/cx'
import { parseURLSearch } from 'utils/general'
import { parseURLSearch, run } from 'utils/general'
import { loadWithPJAX, useOnPJAXDone, usePJAX } from 'utils/hooks/usePJAX'
import { useProgressBar } from 'utils/hooks/useProgressBar'
import * as keyHelper from 'utils/keyHelper'
@ -26,83 +24,10 @@ import { Icon } from './Icon'
import { Theme } from './Theme'
const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
const configContext = useConfigs()
const accessToken = props.configContext.val.accessToken
const [baseSize] = React.useState(() => configContext.val.sideBarWidth)
const { shrinkGitHubHeader } = configContext.val
React.useEffect(() => {
if (platform === GitHub) {
const ele = document.body
if (shrinkGitHubHeader) {
ele.classList.add('shrink-github-header')
} else {
ele.classList.remove('shrink-github-header')
}
}
}, [shrinkGitHubHeader])
const intelligentToggle = configContext.val.intelligentToggle
React.useEffect(() => {
const shouldShow = intelligentToggle === null ? platform.shouldShow() : intelligentToggle
props.setShouldShow(shouldShow)
}, [intelligentToggle, props.metaData])
React.useEffect(() => {
const { init } = props
;(async function () {
if (!accessToken) {
const accessToken = (await trySetUpAccessTokenWithCode()) || undefined
configContext.set({ accessToken })
}
init()
})()
}, [])
React.useEffect(
function attachKeyDown() {
if (props.disabled || !configContext.val.shortcut) return
function onKeyDown(e: KeyboardEvent) {
const keys = keyHelper.parseEvent(e)
if (keys === configContext.val.shortcut) {
props.toggleShowSideBar()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
},
[props.disabled, configContext.val.shortcut],
)
const updateSideBarVisibility = React.useCallback(
function updateSideBarVisibility() {
if (configContext.val.intelligentToggle === null) {
props.setShouldShow(platform.shouldShow())
}
},
[props.metaData?.branchName, configContext.val.intelligentToggle],
)
useOnPJAXDone(updateSideBarVisibility)
const copyFileButton = configContext.val.copyFileButton
useGitHubAttachCopyFileButton(copyFileButton)
const copySnippetButton = configContext.val.copySnippetButton
useGitHubAttachCopySnippetButton(copySnippetButton)
// init again when setting new accessToken
useUpdateEffect(() => {
props.init()
}, [accessToken || '']) // '' prevents duplicated requests
usePJAX()
useProgressBar()
const {
errorDueToAuth,
metaData,
treeData: treeRoot,
treeData,
defer,
error,
shouldShow,
@ -110,7 +35,65 @@ const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
logoContainerElement,
toggleShowSideBar,
toggleShowSettings,
configContext,
} = props
const accessToken = configContext.value.accessToken || ''
const [baseSize] = React.useState(() => configContext.value.sideBarWidth)
useShrinkGitHubHeader(configContext.value.shrinkGitHubHeader)
React.useEffect(() => {
run(async function () {
if (!accessToken) {
const accessToken = await trySetUpAccessTokenWithCode()
if (accessToken) configContext.onChange({ accessToken })
}
})
}, [])
React.useEffect(() => {
props.init()
}, [accessToken])
React.useEffect(
function attachKeyDown() {
if (props.disabled || !configContext.value.shortcut) return
function onKeyDown(e: KeyboardEvent) {
const keys = keyHelper.parseEvent(e)
if (keys === configContext.value.shortcut) {
toggleShowSideBar()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
},
[toggleShowSideBar, props.disabled, configContext.value.shortcut],
)
const intelligentToggle = configContext.value.intelligentToggle
React.useEffect(() => {
const shouldShow = intelligentToggle === null ? platform.shouldShow() : intelligentToggle
props.setShouldShow(shouldShow)
}, [intelligentToggle, props.metaData])
const updateSideBarVisibility = React.useCallback(
function updateSideBarVisibility() {
if (intelligentToggle === null) {
props.setShouldShow(platform.shouldShow())
}
},
[props.metaData?.branchName, intelligentToggle],
)
useOnPJAXDone(updateSideBarVisibility)
useGitHubAttachCopyFileButton(configContext.value.copyFileButton)
useGitHubAttachCopySnippetButton(configContext.value.copySnippetButton)
usePJAX()
useProgressBar()
return (
<Theme>
<div className={'gitako-side-bar'}>
@ -121,15 +104,13 @@ const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
</Portal>
<Resizable className={cx({ hidden: error || !shouldShow })} baseSize={baseSize}>
<div className={'gitako-side-bar-body'}>
<div className={'close-side-bar-button-position'}>
<button className={'close-side-bar-button'} onClick={toggleShowSideBar}>
<Icon className={'action-icon'} type={'x'} />
</button>
</div>
<div className={'gitako-side-bar-content'}>
<div className={'header'}>
{metaData ? <MetaBar metaData={metaData} /> : <div />}
<div className={'close-side-bar-button-position'}>
<button className={'close-side-bar-button'} onClick={toggleShowSideBar}>
<Icon className={'action-icon'} type={'x'} />
</button>
</div>
</div>
<div className={'header'}>{metaData ? <MetaBar metaData={metaData} /> : <div />}</div>
{errorDueToAuth ? (
<AccessDeniedError hasToken={Boolean(accessToken)} />
) : (
@ -137,11 +118,11 @@ const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
<FileExplorer
toggleShowSettings={toggleShowSettings}
metaData={metaData}
treeRoot={treeRoot}
treeRoot={treeData}
freeze={showSettings}
accessToken={accessToken}
loadWithPJAX={loadWithPJAX}
config={configContext.val}
config={configContext.value}
defer={defer}
/>
)
@ -168,6 +149,19 @@ RawGitako.defaultProps = {
export const SideBar = connect(SideBarCore)(RawGitako)
function useShrinkGitHubHeader(shrinkGitHubHeader: boolean) {
React.useEffect(() => {
if (platform === GitHub) {
const target = document.body
if (shrinkGitHubHeader) {
target.classList.add('shrink-github-header')
} else {
target.classList.remove('shrink-github-header')
}
}
}, [shrinkGitHubHeader])
}
function AccessDeniedError({ hasToken }: { hasToken: boolean }) {
return <AccessDeniedDescription hasToken={hasToken} />
}

View file

@ -24,7 +24,7 @@ type Props = {
export function SimpleToggleField({ field, onChange }: Props) {
const { overwrite } = field
const configContext = useConfigs()
const value = configContext.val[field.key]
const value = configContext.value[field.key]
return (
<Field
id={field.key}
@ -57,7 +57,7 @@ export function SimpleToggleField({ field, onChange }: Props) {
type={'checkbox'}
onChange={async e => {
const enabled = e.currentTarget.checked
configContext.set({ [field.key]: overwrite ? overwrite.onChange(enabled) : enabled })
configContext.onChange({ [field.key]: overwrite ? overwrite.onChange(enabled) : enabled })
if (onChange) onChange()
}}
checked={overwrite ? overwrite.value(value) : Boolean(value)}

View file

@ -11,7 +11,7 @@ type Props = {
export function ToggleShowButton({ error, onClick }: Props) {
const ref = React.useRef<HTMLDivElement>(null)
const config = useConfigs()
const [distance, setDistance] = React.useState(config.val.toggleButtonVerticalDistance)
const [distance, setDistance] = React.useState(config.value.toggleButtonVerticalDistance)
const { height } = useWindowSize()
const buttonHeight = 42
React.useEffect(() => {
@ -28,12 +28,12 @@ export function ToggleShowButton({ error, onClick }: Props) {
// updating context
useDebounce(
() => config.set({ toggleButtonVerticalDistance: distance }), // too slow
() => config.onChange({ toggleButtonVerticalDistance: distance }), // too slow
100,
[distance],
)
const toggleIconMode = config.val.toggleButtonContent
const toggleIconMode = config.value.toggleButtonContent
return (
<div ref={ref} className={'gitako-toggle-show-button-wrapper'}>
<button

View file

@ -5,7 +5,7 @@ import { platform } from 'platforms'
import { Gitea } from 'platforms/Gitea'
import { Gitee } from 'platforms/Gitee'
import * as React from 'react'
import { useStates } from 'utils/hooks/useStates'
import { useStateIO } from 'utils/hooks/useStateIO'
import { SettingsSection } from './SettingsSection'
const ACCESS_TOKEN_REGEXP = /^[0-9a-f]+$/
@ -14,23 +14,23 @@ type Props = {}
export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
const configContext = useConfigs()
const hasAccessToken = Boolean(configContext.val.accessToken)
const useAccessToken = useStates('')
const useAccessTokenHint = useStates<React.ReactNode>('')
const focusInput = useStates(false)
const hasAccessToken = Boolean(configContext.value.accessToken)
const useAccessToken = useStateIO('')
const useAccessTokenHint = useStateIO<React.ReactNode>('')
const focusInput = useStateIO(false)
const { val: accessTokenHint } = useAccessTokenHint
const { val: accessToken } = useAccessToken
const { value: accessTokenHint } = useAccessTokenHint
const { value: accessToken } = useAccessToken
React.useEffect(() => {
// clear input when access token updates
useAccessToken.set('')
}, [configContext.val.accessToken])
useAccessToken.onChange('')
}, [configContext.value.accessToken])
const onInputAccessToken = React.useCallback(
({ currentTarget: { value } }: React.FormEvent<HTMLInputElement>) => {
useAccessToken.set(value)
useAccessTokenHint.set(
useAccessToken.onChange(value)
useAccessTokenHint.onChange(
ACCESS_TOKEN_REGEXP.test(value) ? '' : 'Gitako does not recognize the token.',
)
},
@ -42,11 +42,11 @@ export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
}, [])
const saveToken = React.useCallback(
async (hint?: typeof useAccessTokenHint.val) => {
async (hint?: typeof useAccessTokenHint.value) => {
if (accessToken) {
configContext.set({ accessToken })
useAccessToken.set('')
useAccessTokenHint.set(
configContext.onChange({ accessToken })
useAccessToken.onChange('')
useAccessTokenHint.onChange(
hint || (
<span>
<a href="#" onClick={() => window.location.reload()}>
@ -79,7 +79,7 @@ export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
{hasAccessToken ? (
<div>
<Text as="p">Your token has been saved.</Text>
<Button onClick={() => configContext.set({ accessToken: '' })}>Clear</Button>
<Button onClick={() => configContext.onChange({ accessToken: '' })}>Clear</Button>
</div>
) : (
<div>
@ -110,8 +110,8 @@ export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
className={'access-token-input'}
value={accessToken}
placeholder="Or input here manually"
onFocus={() => focusInput.set(true)}
onBlur={() => focusInput.set(false)}
onFocus={() => focusInput.onChange(true)}
onBlur={() => focusInput.onChange(false)}
onChange={onInputAccessToken}
onKeyPress={onPressAccessToken}
/>
@ -121,7 +121,7 @@ export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
</div>
</div>
)}
{accessTokenHint && !focusInput.val && <span className={'hint'}>{accessTokenHint}</span>}
{accessTokenHint && !focusInput.value && <span className={'hint'}>{accessTokenHint}</span>}
</SettingsSection>
)
}

View file

@ -49,11 +49,11 @@ export function FileTreeSettings(props: React.PropsWithChildren<Props>) {
id="recursive-toggle-folder"
options={recursiveToggleFolderOptions}
onChange={v => {
configContext.set({
configContext.onChange({
recursiveToggleFolder: v,
})
}}
value={configContext.val.recursiveToggleFolder}
value={configContext.value.recursiveToggleFolder}
></SelectInput>
</Field>
<Field title="Icons" id="file-tree-icons">
@ -61,11 +61,11 @@ export function FileTreeSettings(props: React.PropsWithChildren<Props>) {
id="file-tree-icons"
options={iconOptions}
onChange={v => {
configContext.set({
configContext.onChange({
icons: v,
})
}}
value={configContext.val.icons}
value={configContext.value.icons}
/>
</Field>
<SimpleToggleField

View file

@ -4,7 +4,7 @@ import { VERSION } from 'env'
import { platform } from 'platforms'
import { GitHub } from 'platforms/GitHub'
import * as React from 'react'
import { useStates } from 'utils/hooks/useStates'
import { useStateIO } from 'utils/hooks/useStateIO'
import { SimpleField, SimpleToggleField } from '../SimpleToggleField'
import { AccessTokenSettings } from './AccessTokenSettings'
import { FileTreeSettings } from './FileTreeSettings'
@ -27,8 +27,8 @@ type Props = {
}
function SettingsBarContent() {
const useReloadHint = useStates<React.ReactNode>('')
const { val: reloadHint } = useReloadHint
const useReloadHint = useStateIO<React.ReactNode>('')
const { value: reloadHint } = useReloadHint
const moreFields: SimpleField[] =
platform === GitHub

View file

@ -5,7 +5,7 @@ import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { Config } from 'utils/configHelper'
import { friendlyFormatShortcut } from 'utils/general'
import { useStates } from 'utils/hooks/useStates'
import { useStateIO } from 'utils/hooks/useStateIO'
import * as keyHelper from 'utils/keyHelper'
import { Field } from './Field'
import { SettingsSection } from './SettingsSection'
@ -27,13 +27,13 @@ const toggleButtonContentOptions: Option<Config['toggleButtonContent']>[] = [
export function SidebarSettings(props: React.PropsWithChildren<Props>) {
const configContext = useConfigs()
const useToggleShowSideBarShortcut = useStates(configContext.val.shortcut)
const { val: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
const focused = useStates(false)
const useToggleShowSideBarShortcut = useStateIO(configContext.value.shortcut)
const { value: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
const focused = useStateIO(false)
React.useEffect(() => {
useToggleShowSideBarShortcut.set(configContext.val.shortcut)
}, [configContext.val.shortcut])
useToggleShowSideBarShortcut.onChange(configContext.value.shortcut)
}, [configContext.value.shortcut])
return (
<SettingsSection title={'Sidebar'}>
@ -43,24 +43,24 @@ export function SidebarSettings(props: React.PropsWithChildren<Props>) {
id="toggle-sidebar-shortcut"
marginRight={1}
className={'toggle-shortcut-input'}
onFocus={() => focused.set(true)}
onBlur={() => focused.set(false)}
placeholder={focused.val ? 'Press key combination' : 'Click here to set'}
onFocus={() => focused.onChange(true)}
onBlur={() => focused.onChange(false)}
placeholder={focused.value ? 'Press key combination' : 'Click here to set'}
value={friendlyFormatShortcut(toggleShowSideBarShortcut)}
onKeyDown={React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault()
e.stopPropagation()
// Clear shortcut with backspace
const shortcut = e.key === 'Backspace' ? '' : keyHelper.parseEvent(e)
useToggleShowSideBarShortcut.set(shortcut)
useToggleShowSideBarShortcut.onChange(shortcut)
}, [])}
readOnly
/>
{configContext.val.shortcut === toggleShowSideBarShortcut ? (
{configContext.value.shortcut === toggleShowSideBarShortcut ? (
<Button
disabled={!configContext.val.shortcut}
disabled={!configContext.value.shortcut}
onClick={() => {
configContext.set({ shortcut: '' })
configContext.onChange({ shortcut: '' })
}}
>
Clear
@ -68,9 +68,9 @@ export function SidebarSettings(props: React.PropsWithChildren<Props>) {
) : (
<Button
onClick={() => {
const { val: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
const { value: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
if (typeof toggleShowSideBarShortcut !== 'string') return
configContext.set({ shortcut: toggleShowSideBarShortcut })
configContext.onChange({ shortcut: toggleShowSideBarShortcut })
}}
>
Save
@ -83,11 +83,11 @@ export function SidebarSettings(props: React.PropsWithChildren<Props>) {
id="toggle-button-content"
options={toggleButtonContentOptions}
onChange={v => {
configContext.set({
configContext.onChange({
toggleButtonContent: v,
})
}}
value={configContext.val.toggleButtonContent}
value={configContext.value.toggleButtonContent}
></SelectInput>
</Field>
<SimpleToggleField

View file

@ -4,12 +4,7 @@ import { Config } from 'utils/configHelper'
type Props = {}
type PartialValSet<T> = {
val: T
set: (val: Partial<T>) => void
}
type ContextShape = PartialValSet<Config>
type ContextShape = IO<Config, Partial<Config>>
export type ConfigsContextShape = ContextShape
export const ConfigsContext = React.createContext<ContextShape | null>(null)
@ -19,7 +14,7 @@ export function ConfigsContextWrapper(props: React.PropsWithChildren<Props>) {
React.useEffect(() => {
configsHelper.get().then(setConfigs)
}, [])
const set = React.useCallback(
const onChange = React.useCallback(
(updatedConfigs: Partial<Config>) => {
const mergedConfigs = { ...configs, ...updatedConfigs } as Config
configsHelper.set(mergedConfigs)
@ -29,7 +24,7 @@ export function ConfigsContextWrapper(props: React.PropsWithChildren<Props>) {
)
if (configs === null) return null
return (
<ConfigsContext.Provider value={{ val: configs, set }}>
<ConfigsContext.Provider value={{ value: configs, onChange: onChange }}>
{props.children}
</ConfigsContext.Provider>
)
@ -37,10 +32,10 @@ export function ConfigsContextWrapper(props: React.PropsWithChildren<Props>) {
export const useConfigs = useNonNullContext(ConfigsContext)
function useNonNullContext<T, R extends Exclude<T, null>>(theContext: React.Context<T>): () => R {
function useNonNullContext<T>(theContext: React.Context<T | null>): () => T {
return () => {
const context = React.useContext(theContext)
if (context === null) throw new Error(`Empty context`)
return context as R
return context
}
}

View file

@ -9,13 +9,6 @@ import './content.scss'
if (platform.resolveMeta()) {
addMiddleware(withErrorLog)
async function init() {
await injectStyles(browser.extension.getURL('content.css'))
const SideBarElement = document.createElement('div')
document.body.appendChild(SideBarElement)
ReactDOM.render(<Gitako />, SideBarElement)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
@ -23,6 +16,13 @@ if (platform.resolveMeta()) {
}
}
async function init() {
await injectStyles(browser.extension.getURL('content.css'))
const SideBarElement = document.createElement('div')
document.body.appendChild(SideBarElement)
ReactDOM.render(<Gitako />, SideBarElement)
}
// injects a copy of stylesheets so that other extensions(e.g. dark reader) could read
// resolves when style is loaded to prevent render without proper styles
async function injectStyles(url: string) {

View file

@ -40,22 +40,22 @@ function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode) {
}
}
type Task = () => void
let visibleNodesGenerator: VisibleNodesGenerator
type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, ConnectorState, Args>
export const setUpTree: BoundMethodCreator<
[Pick<Props, 'treeRoot' | 'metaData'> & { config: Config }]
[
Required<Pick<Props, 'treeRoot' | 'metaData'>> & {
config: Pick<Config, 'compressSingletonFolder' | 'accessToken'>
},
]
> = dispatch => async ({ treeRoot, metaData, config }) => {
if (!treeRoot) return
dispatch.set({ state: 'rendering' })
const { compressSingletonFolder } = config
visibleNodesGenerator = new VisibleNodesGenerator({
root: treeRoot,
compress: compressSingletonFolder,
compress: config.compressSingletonFolder,
async getTreeData(path) {
const { root } = await platform.getTreeData(metaData, path, false, config.accessToken)
return root

View file

@ -2,6 +2,7 @@ import { ConfigsContextShape } from 'containers/ConfigsContext'
import { GetCreatedMethod, MethodCreator } from 'driver/connect'
import { errors, platform, platformName } from 'platforms'
import * as DOMHelper from 'utils/DOMHelper'
import { createPromiseQueue } from 'utils/general'
export type Props = {
configContext: ConfigsContextShape
@ -26,7 +27,6 @@ export type ConnectorState = {
initializingPromise: Promise<void> | null
} & {
init: GetCreatedMethod<typeof init>
setMetaData: GetCreatedMethod<typeof setMetaData>
setShouldShow: GetCreatedMethod<typeof setShouldShow>
toggleShowSideBar: GetCreatedMethod<typeof toggleShowSideBar>
toggleShowSettings: GetCreatedMethod<typeof toggleShowSettings>
@ -34,18 +34,10 @@ export type ConnectorState = {
type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, ConnectorState, Args>
export const init: BoundMethodCreator = dispatch => async () => {
const {
state: { initializingPromise },
} = dispatch.get()
if (initializingPromise) await initializingPromise
const promiseQueue = createPromiseQueue()
let done: any = null // cannot use type `(() => void) | null` here
dispatch.set({
initializingPromise: new Promise(resolve => {
done = () => resolve()
}),
})
export const init: BoundMethodCreator = dispatch => async () => {
const leave = await promiseQueue.enter()
try {
const metaData = platform.resolveMeta()
@ -53,90 +45,85 @@ export const init: BoundMethodCreator = dispatch => async () => {
dispatch.set({ disabled: true })
return
}
const { userName, repoName, branchName } = metaData
DOMHelper.markGitakoReadyState(true)
dispatch.set({
errorDueToAuth: false,
showSettings: false,
logoContainerElement: DOMHelper.insertLogoMountPoint(),
})
dispatch.call(setMetaData, metaData)
const {
props: { configContext },
} = dispatch.get()
const { accessToken } = configContext.val
const { accessToken } = configContext.value
if (!metaData.userName || !metaData.repoName) return
const guessDefaultBranch = 'master'
const getTreeDataAggressively = platform.getTreeData(
const guessDefaultBranch = 'master' // when to switch to 'main'?
let getTreeData = platform.getTreeData(
{
branchName: metaData.branchName || guessDefaultBranch,
userName: metaData.userName,
repoName: metaData.repoName,
branchName: branchName || guessDefaultBranch,
userName,
repoName,
},
'/',
true,
accessToken,
)
const caughtAggressiveError = getTreeDataAggressively?.catch(error => {
// 1. the repo has no master branch
// 2. detect branch name from DOM failed
// 3. not very possible...
// not handle this error immediately
return error
})
let getTreeData = getTreeDataAggressively
const metaDataFromAPI = await platform.getMetaData(
{
userName: metaData.userName,
repoName: metaData.repoName,
},
accessToken,
)
const projectDefaultBranchName = metaDataFromAPI?.defaultBranchName
const detectedBranchName = metaData.branchName
if (
!detectedBranchName &&
projectDefaultBranchName &&
projectDefaultBranchName !== metaData.branchName &&
metaData.type !== 'pull'
) {
// Accessing repository's non-homepage(no branch name in URL, nor in DOM)
// We predicted its default branch to be 'master' and sent aggressive request
// Throw that request due to the repo do not use {defaultBranchName} as default branch
metaData.branchName = projectDefaultBranchName
getTreeData = platform.getTreeData(
{
branchName: metaData.branchName,
userName: metaData.userName,
repoName: metaData.repoName,
},
'/',
true,
accessToken,
)
getTreeData.catch(error => error) // catch it early to prevent the error being raised higher
const metaDataFromAPI = await platform.getMetaData({ userName, repoName }, accessToken)
if (branchName) {
const safeMetaData = {
...metaDataFromAPI,
userName,
repoName,
branchName,
}
dispatch.set({ metaData: safeMetaData })
getTreeData.catch(error => {
dispatch.call(handleError, error)
})
} else {
caughtAggressiveError.then(error => {
// aggressive requested correct branch but ends in failure (e.g. project is empty)
if (error instanceof Error) {
dispatch.call(handleError, error)
}
})
const { defaultBranchName } = metaDataFromAPI
if (!defaultBranchName) {
throw new Error(`Failed resolving default branch name`)
}
const safeMetaData = {
...metaDataFromAPI,
userName,
repoName,
branchName: defaultBranchName,
}
dispatch.set({ metaData: safeMetaData })
if (defaultBranchName !== guessDefaultBranch && metaData.type !== 'pull') {
// Accessing repository's non-homepage(no branch name in URL, nor in DOM)
// We predicted its default branch to be 'master' and sent aggressive request
// Throw that request due to the repo do not use {defaultBranchName} as default branch
getTreeData = platform.getTreeData(
{
branchName: defaultBranchName,
userName,
repoName,
},
'/',
true,
accessToken,
)
}
}
getTreeData
.then(async ({ root: treeData, defer }) => {
if (treeData) {
dispatch.set({ treeData, defer })
}
})
.catch(err => dispatch.call(handleError, err))
Object.assign(metaData, metaDataFromAPI)
dispatch.call(setMetaData, metaData)
const { root: treeData, defer } = await getTreeData
dispatch.set({ treeData, defer })
} catch (err) {
dispatch.call(handleError, err)
} finally {
if (done) done()
}
leave()
}
export const handleError: BoundMethodCreator<[Error]> = dispatch => async err => {
@ -152,7 +139,7 @@ export const handleError: BoundMethodCreator<[Error]> = dispatch => async err =>
dispatch.set({ errorDueToAuth: true })
} else if (err.message === errors.CONNECTION_BLOCKED) {
const { props } = dispatch.get()
if (props.configContext.val.accessToken) {
if (props.configContext.value.accessToken) {
dispatch.call(setError, `Cannot connect to ${platformName}.`)
} else {
dispatch.set({ errorDueToAuth: true })
@ -174,10 +161,10 @@ export const toggleShowSideBar: BoundMethodCreator = dispatch => () => {
dispatch.call(setShouldShow, !shouldShow)
const {
val: { intelligentToggle },
value: { intelligentToggle },
} = configContext
if (intelligentToggle !== null) {
configContext.set({ intelligentToggle: !shouldShow })
configContext.onChange({ intelligentToggle: !shouldShow })
}
}
@ -197,6 +184,3 @@ export const toggleShowSettings: BoundMethodCreator = dispatch => () =>
dispatch.set(({ showSettings }) => ({
showSettings: !showSettings,
}))
export const setMetaData: BoundMethodCreator<[ConnectorState['metaData']]> = dispatch => metaData =>
dispatch.set({ metaData })

8
src/global.d.ts vendored
View file

@ -18,12 +18,16 @@ type TreeNode = {
accessDenied?: boolean
}
type IO<T> = {
type IO<T, ChangeT = T> = {
value: T
onChange(value: T): void
onChange(value: ChangeT): void
}
type Override<Original, Incoming> = Omit<Original, keyof Incoming> & Incoming
type MakeOptional<Original, keys extends keyof Original> = Override<
Original,
Partial<Pick<Original, keys>>
>
type VoidFN<T> = (payload: T) => void

View file

@ -1,6 +1,8 @@
import { raiseError } from 'analytics'
export function parse(): Partial<MetaData> & { path: string[] } {
export function parse(): Partial<Pick<MetaData, 'userName' | 'repoName' | 'type'>> & {
path: string[]
} {
const { pathname } = window.location
let [
,
@ -13,12 +15,13 @@ export function parse(): Partial<MetaData> & { path: string[] } {
return {
userName,
repoName,
branchName: undefined,
type,
path,
}
}
// not working well with non-branch blob
// cannot handle '/' split branch name, should not use when possibly in branch page
export function parseSHA() {
const { type, path } = parse()
return type === 'blob' || type === 'tree' ? path[0] : undefined
@ -30,11 +33,15 @@ export function isInPullPage() {
}
function isCommitPath(path: string[]) {
return isCompleteCommitSHA(path[0])
return path[0] ? isCompleteCommitSHA(path[0]) : false
}
function isCompleteCommitSHA(sha?: string) {
return typeof sha === 'string' && /^[abcdef0-9]{40}$/i.test(sha)
function isCompleteCommitSHA(sha: string) {
return /^[abcdef0-9]{40}$/i.test(sha)
}
function isPossiblyCommitSHA(sha: string) {
return /^[abcdef0-9]+$/i.test(sha)
}
export function getCurrentPath(branchName = '') {
@ -49,22 +56,29 @@ export function getCurrentPath(branchName = '') {
if (path[0] === 'HEAD') path.shift()
else {
const splitBranchName = branchName.split('/')
while (splitBranchName.length) {
if (
splitBranchName[0] === path[0] ||
// Keep consuming as their heads are same
(splitBranchName.length === 1 && splitBranchName[0].startsWith(path[0]))
// This happens when visiting URLs like /blob/{commitSHA}/path/to/file
// and {commitSHA} is shorter than we got from DOM
) {
splitBranchName.shift()
path.shift()
} else {
raiseError(new Error(`branch name and path prefix not match`), {
branchName,
path: parse().path,
})
return []
if (
splitBranchName.length === 1 &&
path.length > 0 &&
isPossiblyCommitSHA(splitBranchName[0]) &&
isPossiblyCommitSHA(path[0]) &&
(splitBranchName[0].startsWith(path[0]) || path[0].startsWith(splitBranchName[0]))
// This happens when visiting URLs like /blob/{commitSHA}/path/to/file
// and {commitSHA} does not match the one got from DOM
) {
splitBranchName.shift()
path.shift()
} else {
while (splitBranchName.length) {
if (splitBranchName[0] === path[0]) {
splitBranchName.shift()
path.shift()
} else {
raiseError(new Error(`branch name and path prefix not match`), {
branchName,
path: parse().path,
})
return []
}
}
}
}

View file

@ -91,26 +91,30 @@ export const GitHub: Platform = {
return null
}
let detectedBranchName
let branchName
if (URLHelper.isInPullPage()) {
detectedBranchName = DOMHelper.getIssueTitle()
branchName = DOMHelper.getIssueTitle()
} else if (
DOMHelper.isInCodePage() &&
!['releases', 'tags'].includes(URLHelper.parse().type || '') // resolve sentry issue #-CK
) {
// not working well with non-branch blob
// cannot handle '/' split branch name, should not use when possibly on branch page
detectedBranchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA()
branchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA()
}
const { userName, repoName, type } = URLHelper.parse()
if (!userName || !repoName) {
return null
}
const metaData = {
...URLHelper.parse(),
branchName: detectedBranchName,
} as MetaData
userName,
repoName,
type,
branchName,
}
return metaData
},
async getMetaData(partialMetaData, accessToken) {
const { userName, repoName } = partialMetaData
async getMetaData({ userName, repoName }, accessToken) {
const data = await API.getRepoMeta(userName, repoName, accessToken)
return {
userUrl: data?.owner?.html_url,
@ -156,9 +160,9 @@ export const GitHub: Platform = {
path: item.filename || '',
type: 'blob',
name: item.filename?.replace(/^.*\//, '') || '',
url: `https://${window.location.host}/${metaData.userName}/${
metaData.repoName
}/pull/${pullId}/files${window.location.search}#${creator(item.filename) || ''}`,
url: `https://${window.location.host}/${userName}/${repoName}/pull/${pullId}/files${
window.location.search
}#${creator(item.filename) || ''}`,
sha: item.sha,
}))
@ -198,32 +202,21 @@ export const GitHub: Platform = {
name: item.path?.replace(/^.*\//, '') || '',
url:
item.url && item.type && item.path
? getUrlForRedirect(
metaData.userName,
metaData.repoName,
metaData.branchName,
item.type,
item.path,
)
? getUrlForRedirect(userName, repoName, branchName, item.type, item.path)
: undefined,
contents: item.type === 'tree' ? [] : undefined,
sha: item.sha,
})),
)
const gitModules = root.contents?.find(item => item.name === '.gitmodules')
if (gitModules) {
if (metaData.userName && metaData.repoName && gitModules.sha) {
const blobData = await API.getBlobData(
metaData.userName,
metaData.repoName,
gitModules.sha,
accessToken,
)
const gitModules = root.contents?.find(
item => item.type === 'blob' && item.name === '.gitmodules',
)
if (gitModules?.sha) {
const blobData = await API.getBlobData(userName, repoName, gitModules.sha, accessToken)
if (blobData && blobData.encoding === 'base64' && blobData.content) {
await resolveGitModules(root, Base64.decode(blobData.content))
}
if (blobData && blobData.encoding === 'base64' && blobData.content) {
await resolveGitModules(root, Base64.decode(blobData.content))
}
}

View file

@ -82,15 +82,23 @@ export const Gitea: Platform = {
return null
}
let detectedBranchName
let branchName
if (DOMHelper.isInCodePage()) {
detectedBranchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA()
branchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA()
}
const { userName, repoName, type } = URLHelper.parse()
if (!userName || !repoName) {
return null
}
const metaData = {
...URLHelper.parse(),
branchName: detectedBranchName,
} as MetaData
userName,
repoName,
type,
branchName,
}
return metaData
},
async getMetaData(partialMetaData, accessToken) {

View file

@ -78,17 +78,24 @@ export const Gitee: Platform = {
return null
}
let detectedBranchName
let branchName
if (DOMHelper.isInCodePage()) {
// not working well with non-branch blob
// cannot handle '/' split branch name, should not use when possibly on branch page
detectedBranchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA()
branchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA()
}
const { userName, repoName, type } = URLHelper.parse()
if (!userName || !repoName) {
return null
}
const metaData = {
...URLHelper.parse(),
branchName: detectedBranchName,
} as MetaData
userName,
repoName,
type,
branchName,
}
return metaData
},
async getMetaData(partialMetaData, accessToken) {

View file

@ -1,6 +1,7 @@
type Platform = {
isEnterprise(): boolean
resolveMeta(): MetaData | null
// branch name might not be available when resolving from DOM and URL
resolveMeta(): MakeOptional<MetaData, 'branchName'> | null
getMetaData(
metaData: Pick<MetaData, 'userName' | 'repoName'>,
accessToken?: string,

View file

@ -303,7 +303,9 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
font-size: 0;
}
}
.#{$name}-side-bar-body {
$button-size: 32px;
width: 100%;
height: 100%;
display: flex;
@ -334,6 +336,37 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
text-align: center;
}
.close-side-bar-button-position {
position: absolute;
right: 6px;
top: 6px;
z-index: 1; // prevent being covered by following elements
.close-side-bar-button {
@include icon-button;
@include button-color;
width: $button-size;
height: $button-size;
border-radius: $button-size;
// feedback to click should be instant
&:not(:active) {
transition: background linear 0.3s;
}
.action-icon {
color: var(--gitako-icon-tertiary);
width: 20px;
height: 20px;
text-align: center;
.octicon {
width: 100%;
height: 100%;
}
}
}
}
.#{$name}-side-bar-content {
display: flex;
flex: 1;
@ -342,7 +375,6 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
min-height: 0; // make content shrinkable
.header {
$button-size: 32px;
position: relative;
.meta-bar {
@ -364,38 +396,6 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
background-color: var(--gitako-branch-name-bg);
}
}
.close-side-bar-button-position {
position: absolute;
right: 6px;
top: 0;
height: 100%;
display: inline-flex;
align-items: center;
.close-side-bar-button {
@include icon-button;
@include button-color;
width: $button-size;
height: $button-size;
border-radius: $button-size;
// feedback to click should be instant
&:not(:active) {
transition: background linear 0.3s;
}
.action-icon {
color: var(--gitako-icon-tertiary);
width: 20px;
height: 20px;
text-align: center;
.octicon {
width: 100%;
height: 100%;
}
}
}
}
}
.description {
@ -478,9 +478,9 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
}
&.disabled {
pointer-events: none;
color: var(--gitako-text-disabled);
}
pointer-events: none;
color: var(--gitako-text-disabled);
}
display: flex;
justify-content: space-between;
align-items: center;

View file

@ -33,7 +33,7 @@ export enum configKeys {
const defaultConfigs: Config = {
sideBarWidth: 260,
shortcut: undefined,
accessToken: undefined,
accessToken: '',
compressSingletonFolder: true,
copyFileButton: true,
copySnippetButton: true,

View file

@ -183,3 +183,23 @@ export function withEffect<Method extends (...args: any[]) => any>(
return returnValue
}
}
export function run<T>(fn: () => T) {
return fn()
}
export function createPromiseQueue() {
let promise: Promise<void>
return {
async enter() {
let leave: () => void
const current = new Promise<void>(resolve => (leave = () => resolve()))
const lastPromise = promise
promise = current!
if (lastPromise) await lastPromise
return leave!
},
}
}

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import { useStates } from './useStates'
import { useStateIO } from './useStateIO'
export function useAsyncMemo<T, D extends any[] | readonly any[]>(
factory: (dependencies: D) => T | Promise<T>,
@ -7,10 +7,10 @@ export function useAsyncMemo<T, D extends any[] | readonly any[]>(
initialValue: T,
): T {
const firstTime = React.useRef(true)
const state = useStates<T>(() => initialValue)
const state = useStateIO<T>(() => initialValue)
React.useEffect(() => {
if (firstTime.current) firstTime.current = false
Promise.resolve(factory(deps)).then(consumed => state.set(() => consumed))
Promise.resolve(factory(deps)).then(consumed => state.onChange(() => consumed))
}, deps)
return state.val
return state.value
}

View file

@ -0,0 +1,11 @@
import * as React from 'react'
export function useStateIO<S>(
initialState: S | (() => S),
): {
value: S
onChange: React.Dispatch<React.SetStateAction<S>>
} {
const [value, onChange] = React.useState(initialState)
return { value, onChange }
}

View file

@ -1,11 +0,0 @@
import * as React from 'react'
export function useStates<S>(
initialState: S | (() => S),
): {
val: S
set: React.Dispatch<React.SetStateAction<S>>
} {
const [val, set] = React.useState(initialState)
return { val, set }
}