Merge branch 'v3' into develop

This commit is contained in:
EnixCoda 2021-06-01 00:09:39 +08:00
commit 68b3aa393f
No known key found for this signature in database
GPG key ID: 0C1A07377913A1DD
65 changed files with 1307 additions and 1068 deletions

View file

@ -4,6 +4,6 @@ describe(`in GitHub homepage`, () => {
beforeAll(() => page.goto('https://github.com'))
it('should not render Gitako', async () => {
await expectToNotFind('.gitako-side-bar .gitako-position-wrapper')
await expectToNotFind('.gitako-side-bar .gitako-side-bar-body-wrapper')
})
})

View file

@ -4,7 +4,7 @@ describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/pull/71'))
it('should render Gitako', async () => {
await expectToFind('.gitako-side-bar .gitako-position-wrapper')
await expectToFind('.gitako-side-bar .gitako-side-bar-body-wrapper')
})
it('should render file list', async () => {

View file

@ -4,8 +4,6 @@ describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/commits/develop'))
it('should not break go back in history', async () => {
// temporarily skip this test as GitHub has unexpected PJAX behaviors
return
for (let i = 0; i < 3; i++) {
const commitLinks = await page.$$(
`#js-repo-pjax-container .TimelineItem-body ol li > div:nth-child(1) a[href*="/commit/"]`,

View file

@ -1,26 +1,33 @@
import { patientClick, selectFileTreeItem, sleep, waitForLegacyPJAXRedirect } from '../utils'
import {
collapseFloatModeSidebar,
expandFloatModeSidebar,
patientClick,
selectFileTreeItem,
sleep,
waitForLegacyPJAXRedirect,
} from '../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/src'))
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/tree/develop/src'))
it('should work with PJAX', async () => {
await sleep(3000)
await patientClick(page, selectFileTreeItem('src/analytics.ts'))
await expandFloatModeSidebar()
await patientClick(selectFileTreeItem('src/analytics.ts'))
await waitForLegacyPJAXRedirect()
await collapseFloatModeSidebar()
await page.click('a[data-selected-links^="repo_issues "]')
await waitForLegacyPJAXRedirect()
await page.click('a[data-tab-item="issues-tab"]')
await waitForLegacyPJAXRedirect()
await page.click('a[data-tab-item="pull-requests-tab"]')
await page.click('a[data-selected-links^="repo_pulls "]')
await waitForLegacyPJAXRedirect()
page.goBack()
await waitForLegacyPJAXRedirect()
await sleep(1000)
page.goBack()
await waitForLegacyPJAXRedirect()
await sleep(1000)
expect(

View file

@ -1,4 +1,5 @@
import {
expandFloatModeSidebar,
expectToFind,
expectToNotFind,
patientClick,
@ -12,7 +13,9 @@ describe(`in Gitako project page`, () => {
it('should work with PJAX', async () => {
await sleep(3000)
await patientClick(page, selectFileTreeItem('.babelrc'))
await expandFloatModeSidebar()
await patientClick(selectFileTreeItem('.babelrc'))
await waitForPJAXAPIRedirect()
// The selector for file content

View file

@ -1,10 +1,16 @@
import { expectToFind, expectToNotFind, scroll, selectFileTreeItem } from '../utils'
import {
expandFloatModeSidebar,
expectToFind,
expectToNotFind,
scroll,
selectFileTreeItem,
} from '../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako'))
it('should render Gitako', async () => {
await expectToFind('.gitako-side-bar .gitako-position-wrapper')
await expectToFind('.gitako-side-bar .gitako-side-bar-body-wrapper')
})
it('should render file list', async () => {
@ -12,6 +18,8 @@ describe(`in Gitako project page`, () => {
})
it('should render while scroll', async () => {
await expandFloatModeSidebar()
const filesEle = await page.waitForSelector('.gitako-side-bar .files')
// node of tsconfig.json should NOT be rendered before scroll down
await expectToNotFind(selectFileTreeItem('tsconfig.json'))

View file

@ -1,5 +1,3 @@
import { Page } from 'puppeteer'
export async function expectToFind(selector: string) {
await expect(page.waitForSelector(selector)).resolves.not.toBeNull()
}
@ -82,7 +80,26 @@ export function selectFileTreeItem(path: string): string {
return `.gitako-side-bar .files a[title="${path}"]`
}
export async function patientClick(page: Page, selector: string) {
export async function patientClick(selector: string) {
await page.waitForSelector(selector)
await page.click(selector)
}
export async function expandFloatModeSidebar() {
const rect = await (await page.$('.gitako-toggle-show-button'))?.evaluate(button => {
const { x, y, width, height } = button.getBoundingClientRect()
// pass required properties to avoid serialization issues
return { x, y, width, height }
})
if (rect) {
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await sleep(500)
}
}
export async function collapseFloatModeSidebar() {
await page.mouse.move(600, 600, {
steps: 100,
})
await sleep(500)
}

View file

@ -61,7 +61,7 @@ module.exports = {
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
maxWorkers: 1, // more than 1 workers may make tabs in background and never renders Gitako's file items.
maxWorkers: 8,
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [

View file

@ -0,0 +1,7 @@
const baseConfig = require('./jest.config')
module.exports = {
...baseConfig,
maxWorkers: 1,
testMatch: ['**/__tests__/non-parallel-cases/*.ts?(x)'],
}

View file

@ -14,13 +14,15 @@
"postinstall": "rm -rf node_modules/@types/react-native && node scripts/fix-pjax-api",
"build": "VERSION=v$(node scripts/get-version.js) NODE_ENV=production webpack",
"roll": "make release",
"test": "NODE_ENV=test jest"
"test": "yarn run test:parallel && yarn run test:non-parallel",
"test:parallel": "NODE_ENV=test jest",
"test:non-parallel": "NODE_ENV=test jest --config jest.non.parallel.config.js"
},
"dependencies": {
"@primer/components": "^22.0.2",
"@primer/css": "^15.2.0",
"@primer/octicons-react": "^10.0.0",
"@sentry/browser": "^5.12.1",
"@sentry/browser": "^6.3.6",
"@types/history": "^4.7.5",
"@types/ini": "^1.3.30",
"@types/js-base64": "^2.3.1",
@ -35,8 +37,8 @@
"nprogress": "^0.2.0",
"pjax-api": "^3.33.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-use": "^13.8.0",
"react-dom": "^17.0.2",
"react-use": "^15.3.0",
"react-window": "^1.8.5",
"styled-components": "^5.2.0",
"webext-domain-permission-toggle": "^1.0.0",
@ -51,7 +53,7 @@
"@babel/preset-env": "^7.3.4",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.3.3",
"@sentry/cli": "^1.51.0",
"@sentry/cli": "^1.64.2",
"@types/firefox-webext-browser": "^70.0.1",
"@types/jest": "^24.0.25",
"@types/node": "^11.10.4",
@ -71,7 +73,7 @@
"raw-loader": "^4.0.0",
"sass": "^1.26.2",
"sass-loader": "^8.0.2",
"typescript": "^4.0.3",
"typescript": "^4.2.4",
"uglifyjs-webpack-plugin": "^2.1.2",
"url-loader": "^1.1.2",
"web-ext": "^4.2.0",

View file

@ -52,6 +52,7 @@ const sentryOptions: Sentry.BrowserOptions = {
}
return breadcrumb
},
autoSessionTracking: false, // this avoids the request when calling `init`
}
Sentry.init(sentryOptions)

View file

@ -1,11 +1,15 @@
import { useConfigs } from 'containers/ConfigsContext'
import { GITHUB_OAUTH } from 'env'
import { platform } from 'platforms'
import { GitHub } from 'platforms/GitHub'
import * as React from 'react'
export function AccessDeniedDescription({ hasToken }: { hasToken: boolean }) {
export function AccessDeniedDescription() {
const configContext = useConfigs()
const hasToken = Boolean(configContext.value.accessToken)
return (
<div className={'description'}>
<div className={'description-area'}>
<h2>Access Denied</h2>
{hasToken ? (
<>

View file

@ -1,4 +1,4 @@
import { Text } from '@primer/components'
import { Label, Text } from '@primer/components'
import { LoadingIndicator } from 'components/LoadingIndicator'
import { Node } from 'components/Node'
import { SearchBar } from 'components/SearchBar'
@ -11,9 +11,12 @@ import * as React from 'react'
import { FixedSizeList, ListChildComponentProps } from 'react-window'
import { cx } from 'utils/cx'
import { focusFileExplorer } from 'utils/DOMHelper'
import { run } from 'utils/general'
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
import { useOnLocationChange } from 'utils/hooks/useOnLocationChange'
import { useOnPJAXDone } from 'utils/hooks/usePJAX'
import { VisibleNodes } from 'utils/VisibleNodesGenerator'
import { SideBarStateContext } from '../containers/SideBarState'
import { Icon } from './Icon'
import { SearchMode, searchModes } from './searchModes'
import { SizeObserver } from './SizeObserver'
@ -27,7 +30,6 @@ type renderNodeContext = {
const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplorer(props) {
const {
state,
visibleNodes,
visibleNodesGenerator,
freeze,
@ -37,11 +39,9 @@ const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplor
onFocusSearchBar,
goTo,
handleKeyDown,
toggleShowSettings,
metaData,
expandTo,
setUpTree,
treeRoot,
defer,
searched,
} = props
@ -59,18 +59,19 @@ const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplor
[updateSearchKey, visibleNodesGenerator],
)
const stateContext = useLoadedContext(SideBarStateContext)
const state = stateContext.value
React.useEffect(() => {
if (treeRoot) {
setUpTree({
treeRoot,
metaData,
config: {
compressSingletonFolder,
accessToken,
},
})
}
}, [setUpTree, treeRoot, metaData, compressSingletonFolder, accessToken])
setUpTree({
metaData,
config: {
compressSingletonFolder,
accessToken,
},
stateContext,
})
}, [setUpTree, metaData, compressSingletonFolder, accessToken])
React.useEffect(() => {
if (visibleNodes?.focusedNode) focusFileExplorer()
@ -139,52 +140,63 @@ const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplor
)
return (
<div
className={cx(`file-explorer`, { freeze })}
tabIndex={-1}
onKeyDown={handleKeyDown}
onClick={freeze ? toggleShowSettings : undefined}
>
{state !== 'done' ? (
<LoadingIndicator text={'Rendering File List...'} />
) : (
visibleNodes &&
renderNodeContext && (
<>
<SearchBar value={searchKey} onSearch={onSearch} onFocus={onFocusSearchBar} />
{searched && visibleNodes.nodes.length === 0 && (
<>
<Text marginTop={6} textAlign="center" color="text.gray">
No results found.
</Text>
{defer && (
<Text textAlign="center" color="gray.4" fontSize="12px">
Lazy mode is ON. Search results are limited to loaded folders.
</Text>
)}
</>
)}
<SizeObserver className={'files'}>
{({ width = 0, height = 0 }) => (
<ListView
height={height}
width={width}
renderNodeContext={renderNodeContext}
expandTo={expandTo}
metaData={metaData}
/>
)}
</SizeObserver>
</>
)
)}
<div className={cx(`file-explorer`, { freeze })} tabIndex={-1} onKeyDown={handleKeyDown}>
{run(() => {
switch (state) {
case 'tree-loading':
return <LoadingIndicator text={'Fetching File List...'} />
case 'tree-rendering':
return <LoadingIndicator text={'Rendering File List...'} />
case 'tree-rendered':
return (
visibleNodes &&
renderNodeContext && (
<>
<SearchBar value={searchKey} onSearch={onSearch} onFocus={onFocusSearchBar} />
{searched && visibleNodes.nodes.length === 0 && (
<>
<Text marginTop={6} textAlign="center" color="text.gray">
No results found.
</Text>
{defer && (
<Text textAlign="center" color="gray.4" fontSize="12px">
Lazy mode is ON. Search results are limited to loaded folders.
</Text>
)}
</>
)}
<SizeObserver className={'files'}>
{({ width = 0, height = 0 }) => (
<ListView
height={height}
width={width}
renderNodeContext={renderNodeContext}
expandTo={expandTo}
metaData={metaData}
/>
)}
</SizeObserver>
{defer && (
<Label
title="File tree data is loaded on demand. And search results are limited."
bg="yellow.5"
color="gray.6"
>
Lazy Mode
</Label>
)}
</>
)
)
}
})}
</div>
)
}
RawFileExplorer.defaultProps = {
freeze: false,
state: 'rendering',
searchKey: '',
visibleNodes: null,
}

View file

@ -1,15 +1,25 @@
import { SideBar } from 'components/SideBar'
import { ConfigsContext, ConfigsContextWrapper } from 'containers/ConfigsContext'
import { ConfigsContextWrapper } from 'containers/ConfigsContext'
import * as React from 'react'
import { ErrorBoundary } from './ErrorBoundary'
import { ErrorBoundary } from '../containers/ErrorBoundary'
import { StateBarErrorContextWrapper } from '../containers/ErrorContext'
import { OAuthWrapper } from '../containers/OAuthWrapper'
import { RepoContextWrapper } from '../containers/RepoContext'
import { StateBarStateContextWrapper } from '../containers/SideBarState'
export function Gitako() {
return (
<ErrorBoundary>
<ConfigsContextWrapper>
<ConfigsContext.Consumer>
{configContext => configContext && <SideBar configContext={configContext} />}
</ConfigsContext.Consumer>
<StateBarStateContextWrapper>
<StateBarErrorContextWrapper>
<OAuthWrapper>
<RepoContextWrapper>
<SideBar />
</RepoContextWrapper>
</OAuthWrapper>
</StateBarErrorContextWrapper>
</StateBarStateContextWrapper>
</ConfigsContextWrapper>
</ErrorBoundary>
)

8
src/components/IIFC.tsx Normal file
View file

@ -0,0 +1,8 @@
import * as React from 'react'
// I tried to install `react-iifc` but that causes TS build errors for unknown reason
// So here the duplicated code is
export function IIFC({ children }: { children(): React.ReactNode }) {
return <>{children()}</>
}

View file

@ -11,135 +11,76 @@ import {
GearIcon as Gear,
GrabberIcon as Grabber,
HourglassIcon as Hourglass,
Icon as OcticonIcon,
IconProps,
MarkdownIcon as Markdown,
OctofaceIcon as Octoface,
PinIcon as Pin,
ReplyIcon as Reply,
SearchIcon as Search,
TabIcon as Tab,
XIcon as X,
} from '@primer/octicons-react'
import * as React from 'react'
import { cx } from 'utils/cx'
function getSVGIconComponent(
type: string,
): {
IconComponent: OcticonIcon
name: string
} {
switch (type) {
case 'comment':
return {
IconComponent: Comment,
name: 'Comment',
}
case 'search':
return {
IconComponent: Search,
name: 'Search',
}
case 'loading':
return {
IconComponent: Clock,
name: 'Clock',
}
case 'hourglass':
return {
IconComponent: Hourglass,
name: 'Hourglass',
}
case 'submodule':
return {
IconComponent: Submodule,
name: 'Submodule',
}
case 'grabber':
return {
IconComponent: Grabber,
name: 'Grabber',
}
case 'octoface':
return {
IconComponent: Octoface,
name: 'Octoface',
}
case 'chevron-down':
return {
IconComponent: ChevronDown,
name: 'ChevronDown',
}
case 'x':
return {
IconComponent: X,
name: 'X',
}
case 'gear':
return {
IconComponent: Gear,
name: 'Gear',
}
case 'folder':
return {
IconComponent: ChevronRight,
name: 'ChevronRight',
}
case 'go-to':
return {
IconComponent: Reply,
name: 'Reply',
}
// not supported in octicon v2 yet
// case '.pdf':
// return {
// IconComponent: FilePdf,
// name: 'FilePdf',
// }
case '.zip':
case '.rar':
case '.7z':
return {
IconComponent: FileZip,
name: 'FileZip',
}
case '.md':
return {
IconComponent: Markdown,
name: 'Markdown',
}
case '.png':
case '.jpg':
case '.gif':
case '.bmp':
return {
IconComponent: FileMedia,
name: 'FileMedia',
}
case '.js':
case '.jsx':
case '.ts':
case '.tsx':
case '.es6':
case '.coffee':
case '.css':
case '.less':
case '.scss':
case '.sass':
return {
IconComponent: FileCode,
name: 'FileCode',
}
// TODO: adapt to more file types
// case '': return FileBinary
// case '': return FileSubmodule
// case '': return FileSymlinkDirectory
// case '': return FileSymlinkFile
default:
return {
IconComponent: File,
name: 'File',
}
}
const iconToComponentMap = {
Search,
Clock,
Comment,
Hourglass,
Submodule,
Grabber,
Octoface,
ChevronDown,
X,
Gear,
ChevronRight,
Reply,
FileZip,
Markdown,
FileMedia,
FileCode,
File,
Pin,
Tab,
}
const defaultIcon = 'File'
const typeToIconComponentMap: {
[type: string]: keyof typeof iconToComponentMap
} = {
search: 'Search',
loading: 'Clock',
hourglass: 'Hourglass',
submodule: 'Submodule',
grabber: 'Grabber',
octoface: 'Octoface',
comment: 'Comment',
x: 'X',
pin: 'Pin',
tab: 'Tab',
gear: 'Gear',
folder: 'ChevronRight',
'chevron-down': 'ChevronDown',
'go-to': 'Reply',
'.zip': 'FileZip',
'.rar': 'FileZip',
'.7z': 'FileZip',
'.md': 'Markdown',
'.png': 'FileMedia',
'.jpg': 'FileMedia',
'.gif': 'FileMedia',
'.bmp': 'FileMedia',
'.js': 'FileCode',
'.jsx': 'FileCode',
'.ts': 'FileCode',
'.tsx': 'FileCode',
'.es6': 'FileCode',
'.coffee': 'FileCode',
'.css': 'FileCode',
'.less': 'FileCode',
'.scss': 'FileCode',
'.sass': 'FileCode',
}
type Props = {
@ -157,7 +98,8 @@ export const Icon = React.memo(function Icon({
}: Props) {
let children: React.ReactNode = null
if (!placeholder) {
const { name, IconComponent } = getSVGIconComponent(type)
const name = typeToIconComponentMap[type] || defaultIcon
const IconComponent = iconToComponentMap[name]
children = <IconComponent className={cx('octicon', name)} {...otherProps} />
}
return (

View file

@ -2,7 +2,7 @@ import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { cx } from 'utils/cx'
import { OperatingSystems, os } from 'utils/general'
import { getFileIconSrc, getFolderIconSrc } from '../utils/parseIconMapCSV'
import { getFileIconSrc, getFolderIconSrc } from 'utils/parseIconMapCSV'
import { Icon } from './Icon'
function getIconType(node: TreeNode) {

View file

@ -1,14 +1,24 @@
import { Icon } from 'components/Icon'
import * as React from 'react'
import { Size } from './Resizable'
import { Size } from './SideBarBodyWrapper'
type Props = {
size: Size
onResize(size: Size): void
onResetSize?(): void
onResizeStateChange?(state: ResizeState): void
style?: React.CSSProperties
}
export function HorizontalResizeHandler({ onResize, size, style }: Props) {
type ResizeState = 'idle' | 'resizing'
export function HorizontalResizeHandler({
onResize,
onResetSize,
onResizeStateChange,
size,
style,
}: Props) {
const pointerDown = React.useRef(false)
const startX = React.useRef(0)
const baseSize = React.useRef(size)
@ -22,6 +32,7 @@ export function HorizontalResizeHandler({ onResize, size, style }: Props) {
startX.current = clientX
pointerDown.current = true
baseSize.current = latestPropSize.current
onResizeStateChange?.('resizing')
}, [])
React.useEffect(() => {
@ -39,6 +50,7 @@ export function HorizontalResizeHandler({ onResize, size, style }: Props) {
if (pointerDown.current) {
pointerDown.current = false
baseSize.current = latestPropSize.current
onResizeStateChange?.('idle')
}
}
window.addEventListener('mouseup', onPointerUp)
@ -46,7 +58,12 @@ export function HorizontalResizeHandler({ onResize, size, style }: Props) {
}, [])
return (
<div className={'gitako-resize-handler'} onMouseDown={onPointerDown} style={style}>
<div
className={'gitako-resize-handler'}
onMouseDown={onPointerDown}
onDoubleClick={onResetSize}
style={style}
>
<Icon type={'grabber'} className={'grabber-icon'} size={20} />
</div>
)

View file

@ -2,106 +2,99 @@ import { AccessDeniedDescription } from 'components/AccessDeniedDescription'
import { FileExplorer } from 'components/FileExplorer'
import { MetaBar } from 'components/MetaBar'
import { Portal } from 'components/Portal'
import { Resizable } from 'components/Resizable'
import { SettingsBar } from 'components/settings/SettingsBar'
import { SideBarBodyWrapper } from 'components/SideBarBodyWrapper'
import { ToggleShowButton } from 'components/ToggleShowButton'
import { connect } from 'driver/connect'
import { SideBarCore } from 'driver/core'
import { ConnectorState, Props } from 'driver/core/SideBar'
import { useConfigs } from 'containers/ConfigsContext'
import { platform } from 'platforms'
import { useGitHubAttachCopyFileButton, useGitHubAttachCopySnippetButton } from 'platforms/GitHub'
import * as React from 'react'
import { cx } from 'utils/cx'
import { parseURLSearch, run } from 'utils/general'
import * as DOMHelper from 'utils/DOMHelper'
import { run } from 'utils/general'
import { useCatchNetworkError } from 'utils/hooks/useCatchNetworkError'
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
import { loadWithPJAX, useOnPJAXDone, usePJAX } from 'utils/hooks/usePJAX'
import { useProgressBar } from 'utils/hooks/useProgressBar'
import { useStateIO } from 'utils/hooks/useStateIO'
import * as keyHelper from 'utils/keyHelper'
import { SideBarErrorContext } from '../containers/ErrorContext'
import { RepoContext } from '../containers/RepoContext'
import { SideBarStateContext } from '../containers/SideBarState'
import { Theme } from '../containers/Theme'
import { useToggleSideBarWithKeyboard } from '../utils/hooks/useToggleSideBarWithKeyboard'
import { Icon } from './Icon'
import { IIFC } from './IIFC'
import { LoadingIndicator } from './LoadingIndicator'
import { Theme } from './Theme'
const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
const {
metaData,
treeData,
state,
defer,
error,
shouldShow,
showSettings,
logoContainerElement,
toggleShowSideBar,
toggleShowSettings,
configContext,
} = props
export function SideBar() {
const metaData = React.useContext(RepoContext)
const state = useLoadedContext(SideBarStateContext).value
const configContext = useConfigs()
const accessToken = configContext.value.accessToken || ''
const [baseSize] = React.useState(() => configContext.value.sideBarWidth)
const $showSettings = useStateIO(false)
const showSettings = $showSettings.value
const toggleShowSettings = React.useCallback(() => $showSettings.onChange(show => !show), [])
const $logoContainerElement = useStateIO<HTMLElement | null>(null)
const hasMetaData = state !== 'disabled' // will be true since retrieving data, cannot use Boolean(metaData)
React.useEffect(() => {
run(async function () {
if (!accessToken) {
const accessToken = await trySetUpAccessTokenWithCode()
if (accessToken) configContext.onChange({ accessToken })
}
})
}, [])
/**
* Catch unexpected PJAX, force trigger init on scope change.
*/
const pageScope = useStateIO(platform.resolvePageScope?.())
useOnPJAXDone(
React.useCallback(
() => pageScope.onChange(platform.resolvePageScope?.(metaData?.defaultBranchName)),
[metaData?.defaultBranchName],
),
)
React.useEffect(() => {
props.init()
}, [accessToken, pageScope.value])
React.useEffect(
function attachKeyDown() {
if (state === '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, state === 'disabled', configContext.value.shortcut],
)
const intelligentToggle = configContext.value.intelligentToggle
// Hide sidebar when error due to auth but token is set #128
const hideSidebarOnInvalidToken: boolean =
intelligentToggle === null && Boolean(state === 'error-due-to-auth' && accessToken)
React.useEffect(() => {
if (hideSidebarOnInvalidToken) {
props.setShouldShow(false)
if (hasMetaData) {
DOMHelper.markGitakoReadyState(true)
$showSettings.onChange(false)
$logoContainerElement.onChange(DOMHelper.insertLogoMountPoint())
} else {
const shouldShow = intelligentToggle === null ? platform.shouldShow() : intelligentToggle
props.setShouldShow(shouldShow)
DOMHelper.markGitakoReadyState(false)
}
}, [intelligentToggle, hideSidebarOnInvalidToken, props.metaData])
}, [hasMetaData])
const updateSideBarVisibility = React.useCallback(
function updateSideBarVisibility() {
if (hideSidebarOnInvalidToken) {
props.setShouldShow(false)
} else if (intelligentToggle === null) {
props.setShouldShow(platform.shouldShow())
}
const sidebarToggleMode = configContext.value.sidebarToggleMode
const intelligentToggle = configContext.value.intelligentToggle
const $shouldShow = useStateIO(intelligentToggle === null ? false : intelligentToggle)
const shouldShow = $shouldShow.value
React.useEffect(() => {
if (sidebarToggleMode === 'persistent') {
DOMHelper.setBodyIndent(shouldShow)
} else {
DOMHelper.setBodyIndent(false)
}
if (shouldShow) {
DOMHelper.focusFileExplorer() // TODO: verify if it works
}
}, [shouldShow, sidebarToggleMode])
// Save expand state on toggle if auto expand if not on
React.useEffect(() => {
if (intelligentToggle !== null) {
configContext.onChange({ intelligentToggle: shouldShow })
}
}, [shouldShow, intelligentToggle])
const error = useLoadedContext(SideBarErrorContext).value
// Lock shouldShow on error
React.useEffect(() => {
if (error && shouldShow) {
$shouldShow.onChange(false)
}
}, [error])
const setShowSideBar = React.useCallback(
(show: typeof $shouldShow.value) => {
if (!error) $shouldShow.onChange(show)
},
[props.metaData?.branchName, intelligentToggle, hideSidebarOnInvalidToken],
[error],
)
useOnPJAXDone(updateSideBarVisibility)
const toggleShowSideBar = React.useCallback(() => {
if (!error) $shouldShow.onChange(show => !show)
}, [error])
useToggleSideBarWithKeyboard(state, configContext, toggleShowSideBar)
useSetShouldShowOnPJAXDone(setShowSideBar)
useGitHubAttachCopyFileButton(configContext.value.copyFileButton)
useGitHubAttachCopySnippetButton(configContext.value.copySnippetButton)
@ -109,81 +102,114 @@ const RawGitako: React.FC<Props & ConnectorState> = function RawGitako(props) {
usePJAX()
useProgressBar()
// Hide sidebar when error due to auth but token is set #128
const hideSidebarOnInvalidToken: boolean =
intelligentToggle === null && Boolean(state === 'error-due-to-auth' && accessToken)
React.useEffect(() => {
if (hideSidebarOnInvalidToken) {
setShowSideBar(false)
}
}, [hideSidebarOnInvalidToken])
return (
<Theme>
<div className={'gitako-side-bar'}>
<Portal into={logoContainerElement}>
{!shouldShow && (
<ToggleShowButton error={error} onClick={error ? undefined : toggleShowSideBar} />
)}
<Portal into={$logoContainerElement.value}>
<ToggleShowButton
error={error}
className={cx({
hidden: shouldShow,
})}
onHover={sidebarToggleMode === 'float' ? () => setShowSideBar(true) : undefined}
onClick={toggleShowSideBar}
/>
</Portal>
<Resizable className={cx({ hidden: error || !shouldShow })} baseSize={baseSize}>
<SideBarBodyWrapper
className={cx(`toggle-mode-${sidebarToggleMode}`, {
collapsed: error || !shouldShow,
})}
baseSize={baseSize}
onLeave={sidebarToggleMode === 'float' ? () => setShowSideBar(false) : undefined}
>
<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'} />
{sidebarToggleMode === 'persistent' && (
<button
title={'Collapse sidebar'}
className={'close-side-bar-button'}
onClick={toggleShowSideBar}
>
<Icon className={'action-icon'} type={'tab'} />
</button>
)}
<button
title={'Toggle sidebar dock mode between float and persistent'}
className={cx('close-side-bar-button', {
active: sidebarToggleMode === 'persistent',
})}
onClick={() =>
configContext.onChange({
sidebarToggleMode: sidebarToggleMode === 'float' ? 'persistent' : 'float',
})
}
>
<Icon className={'action-icon'} type={'pin'} />
</button>
</div>
<div className={'gitako-side-bar-content'}>
{(() => {
<div
className={'gitako-side-bar-content'}
onClick={showSettings ? toggleShowSettings : undefined}
>
{run(() => {
switch (state) {
case 'loading-meta':
case 'disabled':
return null
case 'getting-access-token':
return <LoadingIndicator text={'Getting access token...'} />
case 'meta-loading':
return <LoadingIndicator text={'Fetching repo meta...'} />
case 'loading-tree':
return <LoadingIndicator text={'Fetching File List...'} />
case 'idle':
case 'error-due-to-auth':
return <AccessDeniedDescription />
default:
return metaData ? (
<>
<div className={'header'}>
<MetaBar metaData={metaData} />
</div>
<FileExplorer
toggleShowSettings={toggleShowSettings}
metaData={metaData}
treeRoot={treeData}
freeze={showSettings}
accessToken={accessToken}
loadWithPJAX={loadWithPJAX}
config={configContext.value}
defer={defer}
/>
<IIFC>
{() => (
<FileExplorer
metaData={metaData}
freeze={showSettings}
accessToken={accessToken}
loadWithPJAX={loadWithPJAX}
config={configContext.value}
catchNetworkErrors={useCatchNetworkError()}
/>
)}
</IIFC>
</>
) : null
case 'error-due-to-auth':
return <AccessDeniedDescription hasToken={Boolean(accessToken)} />
}
})()}
})}
</div>
<SettingsBar
defer={defer}
toggleShowSettings={toggleShowSettings}
activated={showSettings}
/>
<SettingsBar toggleShowSettings={toggleShowSettings} activated={showSettings} />
</div>
</Resizable>
</SideBarBodyWrapper>
</div>
</Theme>
)
}
RawGitako.defaultProps = {
shouldShow: false,
showSettings: false,
state: 'loading-meta',
}
export const SideBar = connect(SideBarCore)(RawGitako)
async function trySetUpAccessTokenWithCode() {
const search = parseURLSearch()
if ('code' in search) {
const accessToken = await platform.setOAuth(search.code)
if (!accessToken) alert(`Gitako: The OAuth token has expired, please try again.`)
window.history.replaceState(
{},
'removed search param',
window.location.pathname.replace(window.location.search, ''),
)
return accessToken
}
function useSetShouldShowOnPJAXDone(setShouldShow: (value: boolean) => void) {
const { intelligentToggle, sidebarToggleMode } = useConfigs().value
const updateSideBarVisibility = React.useCallback(() => {
if (intelligentToggle === null && sidebarToggleMode === 'persistent') {
setShouldShow(platform.shouldShow())
}
}, [intelligentToggle, sidebarToggleMode])
React.useEffect(() => {
updateSideBarVisibility()
}, [updateSideBarVisibility])
useOnPJAXDone(updateSideBarVisibility)
}

View file

@ -2,23 +2,31 @@ import { HorizontalResizeHandler } from 'components/ResizeHandler'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { useDebounce, useWindowSize } from 'react-use'
import { defaultConfigs } from 'utils/config/helper'
import { cx } from 'utils/cx'
import { setResizingState } from 'utils/DOMHelper'
import * as features from 'utils/features'
import { useCSSVariable } from './useCSSVariable'
import { useCSSVariable } from 'utils/hooks/useCSSVariable'
export type Size = number
type Props = {
baseSize: Size
className?: string
onLeave?: React.HTMLAttributes<HTMLElement>['onMouseLeave']
}
const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100
const MINIMAL_WIDTH = 240
export function Resizable({ baseSize, className, children }: React.PropsWithChildren<Props>) {
export function SideBarBodyWrapper({
baseSize,
className,
children,
onLeave,
}: React.PropsWithChildren<Props>) {
const [size, setSize] = React.useState(baseSize)
const configContext = useConfigs()
const blockLeaveRef = React.useRef(false)
React.useEffect(() => {
setSize(baseSize)
@ -47,10 +55,28 @@ export function Resizable({ baseSize, className, children }: React.PropsWithChil
else if (size < MINIMAL_WIDTH) setSize(MINIMAL_WIDTH)
else setSize(size)
}, [])
const onMouseLeave = React.useCallback(
e => {
if (blockLeaveRef.current) return
onLeave?.(e)
},
[onLeave],
)
return (
<div className={cx('gitako-position-wrapper', className)}>
<div className={'gitako-position-content'}>{children}</div>
{features.resize && <HorizontalResizeHandler onResize={onResize} size={size} />}
<div className={cx('gitako-side-bar-body-wrapper', className)} onMouseLeave={onMouseLeave}>
<div className={'gitako-side-bar-body-wrapper-content'}>{children}</div>
{features.resize && (
<HorizontalResizeHandler
onResize={onResize}
onResetSize={() => setSize(defaultConfigs.sideBarWidth)}
onResizeStateChange={state => {
blockLeaveRef.current = state === 'resizing'
}}
size={size}
/>
)}
</div>
)
}

View file

@ -1,27 +1,28 @@
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { Config } from 'utils/configHelper'
import { Config } from 'utils/config/helper'
import { Field } from './settings/Field'
export type SimpleField = {
key: keyof Config
export type SimpleField<Key extends keyof Config> = {
key: Key
label: string
wikiLink?: string
tooltip?: string
description?: string
disabled?: boolean
overwrite?: {
value: <T>(value: T) => boolean
onChange: (checked: boolean) => any
value: (value: Config[Key]) => boolean
onChange: (checked: boolean) => Config[Key]
}
}
type Props = {
field: SimpleField
type Props<Key extends keyof Config> = {
field: SimpleField<Key>
onChange?(): void
}
export function SimpleToggleField({ field, onChange }: Props) {
export function SimpleToggleField<Key extends keyof Config>({ field, onChange }: Props<Key>) {
const { overwrite } = field
const configContext = useConfigs()
const value = configContext.value[field.key]
@ -54,6 +55,7 @@ export function SimpleToggleField({ field, onChange }: Props) {
<input
id={field.key}
name={field.key}
disabled={field.disabled}
type={'checkbox'}
onChange={async e => {
const enabled = e.currentTarget.checked

View file

@ -2,13 +2,16 @@ import iconSrc from 'assets/icons/Gitako.png'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { useDebounce, useWindowSize } from 'react-use'
import { cx } from 'utils/cx'
import { Icon } from './Icon'
type Props = {
error?: string
error?: string | null
className?: React.HTMLAttributes<HTMLButtonElement>['className']
onHover?: React.HTMLAttributes<HTMLButtonElement>['onMouseEnter']
} & Pick<React.HTMLAttributes<HTMLButtonElement>, 'onClick'>
export function ToggleShowButton({ error, onClick }: Props) {
export function ToggleShowButton({ error, className, onClick, onHover }: Props) {
const ref = React.useRef<HTMLDivElement>(null)
const config = useConfigs()
const [distance, setDistance] = React.useState(config.value.toggleButtonVerticalDistance)
@ -35,10 +38,13 @@ export function ToggleShowButton({ error, onClick }: Props) {
const toggleIconMode = config.value.toggleButtonContent
return (
<div ref={ref} className={'gitako-toggle-show-button-wrapper'}>
<div ref={ref} className={cx('gitako-toggle-show-button-wrapper', className)}>
<button
className={'gitako-toggle-show-button'}
className={cx('gitako-toggle-show-button', {
error,
})}
onClick={onClick}
onMouseEnter={onHover}
draggable
onDragStart={event => {
hideDragPreview(event)

View file

@ -2,7 +2,7 @@ import { wikiLinks } from 'components/settings/SettingsBar'
import { SimpleToggleField } from 'components/SimpleToggleField'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { Config } from 'utils/configHelper'
import { Config } from 'utils/config/helper'
import { Option, SelectInput } from '../SelectInput'
import { Field } from './Field'
import { SettingsSection } from './SettingsSection'
@ -54,7 +54,7 @@ export function FileTreeSettings(props: React.PropsWithChildren<Props>) {
})
}}
value={configContext.value.recursiveToggleFolder}
></SelectInput>
/>
</Field>
<Field title="Icons" id="file-tree-icons">
<SelectInput<Config['icons']>

View file

@ -1,4 +1,4 @@
import { Label, Link } from '@primer/components'
import { Link } from '@primer/components'
import { Icon } from 'components/Icon'
import { VERSION } from 'env'
import { platform } from 'platforms'
@ -21,7 +21,6 @@ export const wikiLinks = {
}
type Props = {
defer?: boolean
activated: boolean
toggleShowSettings: () => void
}
@ -30,7 +29,7 @@ function SettingsBarContent() {
const useReloadHint = useStateIO<React.ReactNode>('')
const { value: reloadHint } = useReloadHint
const moreFields: SimpleField[] =
const moreFields: SimpleField<'copyFileButton' | 'copySnippetButton'>[] =
platform === GitHub
? [
{
@ -78,7 +77,7 @@ function SettingsBarContent() {
}
export function SettingsBar(props: Props) {
const { defer, toggleShowSettings, activated } = props
const { toggleShowSettings, activated } = props
return (
<div className={'gitako-settings-bar'}>
{activated && <SettingsBarContent />}
@ -93,15 +92,6 @@ export function SettingsBar(props: Props) {
{VERSION}
</Link>
<div className={'header-right'}>
{defer && (
<Label
title="File tree data is loaded on demand. And search results are limited."
bg="yellow.5"
color="gray.6"
>
Lazy Mode
</Label>
)}
<button className={'settings-button'} onClick={toggleShowSettings}>
{activated ? (
<Icon type={'chevron-down'} className={'hide-settings-icon'} />

View file

@ -3,7 +3,7 @@ import { Option, SelectInput } from 'components/SelectInput'
import { SimpleToggleField } from 'components/SimpleToggleField'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { Config } from 'utils/configHelper'
import { Config } from 'utils/config/helper'
import { friendlyFormatShortcut } from 'utils/general'
import { useStateIO } from 'utils/hooks/useStateIO'
import * as keyHelper from 'utils/keyHelper'
@ -88,16 +88,21 @@ export function SidebarSettings(props: React.PropsWithChildren<Props>) {
})
}}
value={configContext.value.toggleButtonContent}
></SelectInput>
/>
</Field>
<SimpleToggleField
field={{
key: 'intelligentToggle',
label: 'Auto expand',
tooltip:
'Gitako will expand when exploring source files, pull requests, etc. And collapse otherwise.',
disabled: configContext.value.sidebarToggleMode === 'float',
tooltip: `Gitako will expand when exploring source files, pull requests, etc. And collapse otherwise.${
configContext.value.sidebarToggleMode === 'float'
? '\nNow disabled as sidebar is in float mode.'
: ''
}`,
overwrite: {
value: enabled => enabled === null,
value: enabled =>
configContext.value.sidebarToggleMode === 'float' ? false : enabled === null,
onChange: checked => (checked ? null : true),
},
}}

View file

@ -1,6 +1,5 @@
import * as React from 'react'
import * as configsHelper from 'utils/configHelper'
import { Config } from 'utils/configHelper'
import { Config, configHelper } from 'utils/config/helper'
type Props = {}
@ -12,19 +11,19 @@ export const ConfigsContext = React.createContext<ContextShape | null>(null)
export function ConfigsContextWrapper(props: React.PropsWithChildren<Props>) {
const [configs, setConfigs] = React.useState<Config | null>(null)
React.useEffect(() => {
configsHelper.get().then(setConfigs)
configHelper.get().then(setConfigs)
}, [])
const onChange = React.useCallback(
(updatedConfigs: Partial<Config>) => {
const mergedConfigs = { ...configs, ...updatedConfigs } as Config
configsHelper.set(mergedConfigs)
configHelper.set(mergedConfigs)
setConfigs(mergedConfigs)
},
[configs, setConfigs],
)
if (configs === null) return null
return (
<ConfigsContext.Provider value={{ value: configs, onChange: onChange }}>
<ConfigsContext.Provider value={{ value: configs, onChange }}>
{props.children}
</ConfigsContext.Provider>
)

View file

@ -0,0 +1,12 @@
import * as React from 'react'
import { useStateIO } from 'utils/hooks/useStateIO'
export type SideBarErrorContextShape = IO<string | null>
export const SideBarErrorContext = React.createContext<SideBarErrorContextShape | null>(null)
export function StateBarErrorContextWrapper({ children }: React.PropsWithChildren<{}>) {
const $error = useStateIO<string | null>(null)
return <SideBarErrorContext.Provider value={$error}>{children}</SideBarErrorContext.Provider>
}

View file

@ -0,0 +1,65 @@
import { useConfigs } from 'containers/ConfigsContext'
import { platform } from 'platforms'
import * as React from 'react'
import { parseURLSearch, run } from 'utils/general'
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
import { useStateIO } from 'utils/hooks/useStateIO'
import { SideBarStateContext } from './SideBarState'
/**
* Setup access token before sending other requests
*/
export function OAuthWrapper({ children }: React.PropsWithChildren<{}>) {
const running = useGetAccessToken()
const $state = useLoadedContext(SideBarStateContext)
const firstRenderRef = React.useRef(true)
React.useEffect(() => {
if (running) {
$state.onChange('getting-access-token')
} else if (firstRenderRef.current) {
firstRenderRef.current = false
} else {
$state.onChange('after-getting-access-token')
}
}, [running])
// block children rendering on the first render if setting token
if (running && $state.value !== 'getting-access-token') return null
return <>{children}</>
}
function useGetAccessToken() {
const $block = useStateIO(() => Boolean(getCodeSearchParam()))
const configContext = useConfigs()
const { accessToken } = configContext.value
React.useEffect(() => {
run(async function () {
const code = getCodeSearchParam()
if (code && !accessToken) {
const accessToken = await getAccessTokenWithCode(code)
if (accessToken) configContext.onChange({ accessToken })
}
$block.onChange(false)
})
}, [])
return $block.value
}
function getCodeSearchParam() {
return parseURLSearch().get('code')
}
async function getAccessTokenWithCode(code: string) {
const accessToken = await platform.setOAuth(code)
if (!accessToken) alert(`Gitako: The OAuth token may have expired, please try again.`)
const search = parseURLSearch()
search.delete('code')
window.history.replaceState(
{},
'removed search param',
window.location.pathname.replace(window.location.search, '?' + search.toString()),
)
return accessToken
}

View file

@ -0,0 +1,107 @@
import { useConfigs } from 'containers/ConfigsContext'
import { platform } from 'platforms'
import * as React from 'react'
import { useEffectOnSerializableUpdates } from 'utils/hooks/useEffectOnSerializableUpdates'
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
import { useOnPJAXDone } from 'utils/hooks/usePJAX'
import { useStateIO } from 'utils/hooks/useStateIO'
import { useCatchNetworkError } from '../utils/hooks/useCatchNetworkError'
import { SideBarStateContext } from './SideBarState'
export const RepoContext = React.createContext<MetaData | null>(null)
export function RepoContextWrapper({ children }: React.PropsWithChildren<{}>) {
const partialMetaData = usePartialMetaData()
const defaultBranch = useDefaultBranch(partialMetaData)
const metaData = useMetaData(partialMetaData, defaultBranch)
return <RepoContext.Provider value={metaData}>{children}</RepoContext.Provider>
}
function resolvePartialMetaData() {
const partialMetaData = platform.resolvePartialMetaData()
if (partialMetaData) {
const { userName, repoName, type } = partialMetaData
return {
userName,
repoName,
type: type === 'pull' ? type : undefined,
}
} else {
return partialMetaData
}
}
function usePartialMetaData(): PartialMetaData | null {
const $state = useLoadedContext(SideBarStateContext)
const isGettingAccessToken = $state.value === 'getting-access-token' // will be false after getting access token and trigger meta-resolve progress
// sync along URL and DOM
const $partialMetaData = useStateIO(isGettingAccessToken ? null : resolvePartialMetaData)
const $committedPartialMetaData = useStateIO($partialMetaData.value)
const setPartialMetaData = () => $partialMetaData.onChange(resolvePartialMetaData())
React.useEffect(() => {
if (!isGettingAccessToken) setPartialMetaData()
}, [isGettingAccessToken])
useOnPJAXDone(setPartialMetaData)
useEffectOnSerializableUpdates(
$partialMetaData.value,
JSON.stringify,
$committedPartialMetaData.onChange,
)
React.useEffect(() => {
if (!$partialMetaData.value && !isGettingAccessToken) {
$state.onChange('disabled')
}
}, [$partialMetaData.value])
return $committedPartialMetaData.value
}
function useBranchName(): MetaData['branchName'] | null {
// sync along URL and DOM
const $branchName = useStateIO(() => platform.resolvePartialMetaData()?.branchName || null)
useOnPJAXDone(() => $branchName.onChange(platform.resolvePartialMetaData()?.branchName || null))
return $branchName.value
}
function useDefaultBranch(partialMetaData: PartialMetaData | null) {
const { accessToken } = useConfigs().value
const $state = useLoadedContext(SideBarStateContext)
const $defaultBranch = useStateIO<string | null>(null)
const catchNetworkError = useCatchNetworkError()
React.useEffect(() => {
catchNetworkError(async () => {
if (!partialMetaData) return
$state.onChange('meta-loading')
const defaultBranch = await platform.getDefaultBranchName(partialMetaData, accessToken)
$defaultBranch.onChange(defaultBranch)
})
}, [partialMetaData, accessToken])
return $defaultBranch.value
}
function useMetaData(
partialMetaData: PartialMetaData | null,
defaultBranchName: MetaData['defaultBranchName'] | null,
) {
const $state = useLoadedContext(SideBarStateContext)
const $metaData = useStateIO<MetaData | null>(null)
const branchName = useBranchName()
const theBranch = branchName && branchName !== defaultBranchName ? branchName : defaultBranchName
React.useEffect(() => {
if (partialMetaData && defaultBranchName && theBranch) {
const { userName, repoName } = partialMetaData
const safeMetaData: MetaData = {
userName,
repoName,
branchName: theBranch,
defaultBranchName,
}
$metaData.onChange(safeMetaData)
$state.onChange('meta-loaded')
} else {
$metaData.onChange(null)
}
}, [partialMetaData, defaultBranchName, theBranch])
return $metaData.value
}

View file

@ -0,0 +1,28 @@
import * as React from 'react'
import { useStateIO } from 'utils/hooks/useStateIO'
export type SideBarState =
| 'disabled'
| 'getting-access-token'
| 'after-getting-access-token' // mid-state for a smoother state switch out of 'getting-access-token'
| 'meta-loading'
| 'meta-loaded'
| 'tree-loading'
| 'tree-rendering'
| 'tree-rendered'
| 'idle'
| 'error-due-to-auth'
export type SideBarStateContextShape = IO<SideBarState>
export const SideBarStateContext = React.createContext<SideBarStateContextShape | null>(null)
export function StateBarStateContextWrapper({ children }: React.PropsWithChildren<{}>) {
const $state = useStateIO<SideBarState>('disabled')
return (
<SideBarStateContext.Provider value={$state}>
{$state.value !== null && children}
</SideBarStateContext.Provider>
)
}

View file

@ -6,7 +6,7 @@ import * as React from 'react'
import * as ReactDOM from 'react-dom'
import './content.scss'
if (platform.resolveMeta()) {
if (platform.resolvePartialMetaData()) {
addMiddleware(withErrorLog)
if (document.readyState === 'loading') {
@ -26,7 +26,7 @@ async function init() {
// 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) {
return new Promise(resolve => {
return new Promise<void>(resolve => {
const linkElement = document.createElement('link')
linkElement.setAttribute('rel', 'stylesheet')
linkElement.setAttribute('href', url)

View file

@ -1,26 +1,25 @@
import { SideBarStateContextShape } from 'containers/SideBarState'
import { GetCreatedMethod, MethodCreator } from 'driver/connect'
import { platform } from 'platforms'
import { Config } from 'utils/configHelper'
import { Config } from 'utils/config/helper'
import * as DOMHelper from 'utils/DOMHelper'
import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
export type Props = {
treeRoot?: TreeNode
metaData: MetaData
freeze: boolean
accessToken: string | undefined
toggleShowSettings: React.MouseEventHandler
config: Config
loadWithPJAX(url: string): void
defer?: boolean
catchNetworkErrors: <T>(fn: () => T) => Promise<T | undefined>
}
export type ConnectorState = {
state: 'rendering' | 'done'
visibleNodesGenerator: VisibleNodesGenerator | null
visibleNodes: VisibleNodes | null
searchKey: string
searched: boolean // derived state from searchKey, = !!searchKey
defer: boolean
handleKeyDown: GetCreatedMethod<typeof handleKeyDown>
updateSearchKey: GetCreatedMethod<typeof updateSearchKey>
@ -45,37 +44,58 @@ type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, Connecto
export const setUpTree: BoundMethodCreator<
[
Required<Pick<Props, 'treeRoot' | 'metaData'>> & {
config: Pick<Config, 'compressSingletonFolder' | 'accessToken'>
},
{ stateContext: SideBarStateContextShape } & Required<Pick<Props, 'metaData'>> & {
config: Pick<Config, 'compressSingletonFolder' | 'accessToken'>
},
]
> = dispatch => async ({ treeRoot, metaData, config }) => {
dispatch.set({ state: 'rendering' })
> = dispatch => ({ stateContext, metaData, config }) => {
const {
props: { catchNetworkErrors },
} = dispatch.get()
const visibleNodesGenerator = new VisibleNodesGenerator({
root: treeRoot,
compress: config.compressSingletonFolder,
async getTreeData(path) {
const { root } = await platform.getTreeData(metaData, path, false, config.accessToken)
return root
},
})
dispatch.set({ visibleNodesGenerator })
visibleNodesGenerator.onUpdate(visibleNodes => dispatch.set({ visibleNodes }))
catchNetworkErrors(async () => {
const { userName, repoName, branchName } = metaData
if (platform.shouldExpandAll?.()) {
const unsubscribe = visibleNodesGenerator.onUpdate(visibleNodes => {
unsubscribe()
visibleNodes.nodes.forEach(node =>
dispatch.call(toggleNodeExpansion, node, { recursive: true }),
)
stateContext.onChange('tree-loading')
const { root: treeRoot, defer = false } = await platform.getTreeData(
{
branchName: branchName,
userName,
repoName,
},
'/',
true,
config.accessToken,
)
stateContext.onChange('tree-rendering')
dispatch.set({ defer })
const visibleNodesGenerator = new VisibleNodesGenerator({
root: treeRoot,
compress: config.compressSingletonFolder,
async getTreeData(path) {
const { root } = await platform.getTreeData(metaData, path, false, config.accessToken)
return root
},
})
} else {
const targetPath = platform.getCurrentPath(metaData.branchName)
if (targetPath) dispatch.call(goTo, targetPath)
}
dispatch.set({ visibleNodesGenerator })
visibleNodesGenerator.onUpdate(visibleNodes => dispatch.set({ visibleNodes }))
dispatch.set({ state: 'done' })
if (platform.shouldExpandAll?.()) {
const unsubscribe = visibleNodesGenerator.onUpdate(visibleNodes => {
unsubscribe()
visibleNodes.nodes.forEach(node =>
dispatch.call(toggleNodeExpansion, node, { recursive: true }),
)
})
} else {
const targetPath = platform.getCurrentPath(metaData.branchName)
if (targetPath) dispatch.call(goTo, targetPath)
}
stateContext.onChange('tree-rendered')
})
}
export const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch => event => {

View file

@ -1,185 +0,0 @@
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
}
export type ConnectorState = {
// error message
error?: string
// whether Gitako side bar should be shown
shouldShow: boolean
// whether show settings pane
showSettings: boolean
// meta data for the repository
metaData?: MetaData
// file tree data
treeData?: TreeNode
state: 'loading-meta' | 'loading-tree' | 'idle' | 'error-due-to-auth' | 'disabled'
logoContainerElement: Element | null
defer?: boolean
} & {
init: GetCreatedMethod<typeof init>
setShouldShow: GetCreatedMethod<typeof setShouldShow>
toggleShowSideBar: GetCreatedMethod<typeof toggleShowSideBar>
toggleShowSettings: GetCreatedMethod<typeof toggleShowSettings>
}
type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, ConnectorState, Args>
const promiseQueue = createPromiseQueue()
export const init: BoundMethodCreator = dispatch => async () => {
const leave = await promiseQueue.enter()
try {
dispatch.set({ state: 'loading-meta' })
const metaData = platform.resolveMeta()
if (!metaData) {
dispatch.set({ state: 'disabled' })
return
}
const { userName, repoName, branchName, defaultBranchName } = metaData
DOMHelper.markGitakoReadyState(true)
dispatch.set({
showSettings: false,
logoContainerElement: DOMHelper.insertLogoMountPoint(),
})
const {
props: { configContext },
} = dispatch.get()
const { accessToken } = configContext.value
const guessDefaultBranch = 'master' // when to switch to 'main'?
let getTreeData = platform.getTreeData(
{
branchName: branchName || guessDefaultBranch,
userName,
repoName,
},
'/',
true,
accessToken,
)
getTreeData.catch(error => error) // catch it early to prevent the error being raised higher
if (branchName) {
const safeMetaData: MetaData = {
userName,
repoName,
branchName,
defaultBranchName,
}
dispatch.set({ metaData: safeMetaData })
getTreeData.catch(error => {
dispatch.call(handleError, error)
})
} else {
const defaultBranchName = await platform.getDefaultBranchName(
{ userName, repoName },
accessToken,
)
if (!defaultBranchName) {
throw new Error(`Failed resolving default branch name`)
}
const safeMetaData: MetaData = {
userName,
repoName,
branchName: defaultBranchName,
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,
)
}
}
dispatch.set({ state: 'loading-tree' })
const { root: treeData, defer } = await getTreeData
dispatch.set({ state: 'idle', treeData, defer })
} catch (err) {
dispatch.call(handleError, err)
}
leave()
}
export const handleError: BoundMethodCreator<[Error]> = dispatch => async err => {
if (err.message === errors.EMPTY_PROJECT) {
dispatch.call(setError, 'This project seems to be empty.')
} else if (err.message === errors.BLOCKED_PROJECT) {
dispatch.call(setError, 'Access to the project is blocked.')
} else if (
err.message === errors.NOT_FOUND ||
err.message === errors.BAD_CREDENTIALS ||
err.message === errors.API_RATE_LIMIT
) {
dispatch.set({ state: 'error-due-to-auth' })
} else if (err.message === errors.CONNECTION_BLOCKED) {
const { props } = dispatch.get()
if (props.configContext.value.accessToken) {
dispatch.call(setError, `Cannot connect to ${platformName}.`)
} else {
dispatch.set({ state: 'error-due-to-auth' })
}
} else if (err.message === errors.SERVER_FAULT) {
dispatch.call(setError, `${platformName} server went down.`)
} else {
DOMHelper.markGitakoReadyState(false)
dispatch.call(setError, 'Some thing went wrong.')
throw err
}
}
export const toggleShowSideBar: BoundMethodCreator = dispatch => () => {
const {
state: { shouldShow },
props: { configContext },
} = dispatch.get()
dispatch.call(setShouldShow, !shouldShow)
const {
value: { intelligentToggle },
} = configContext
if (intelligentToggle !== null) {
configContext.onChange({ intelligentToggle: !shouldShow })
}
}
export const setShouldShow: BoundMethodCreator<
[ConnectorState['shouldShow']]
> = dispatch => shouldShow => {
dispatch.set({ shouldShow }, shouldShow ? DOMHelper.focusFileExplorer : undefined)
DOMHelper.setBodyIndent(shouldShow)
}
export const setError: BoundMethodCreator<[ConnectorState['error']]> = dispatch => error => {
dispatch.set({ error })
dispatch.call(setShouldShow, false)
}
export const toggleShowSettings: BoundMethodCreator = dispatch => () =>
dispatch.set(({ showSettings }) => ({
showSettings: !showSettings,
}))

View file

@ -4,8 +4,5 @@ import {
ConnectorState as FileExplorerConnectorState,
Props as FileExplorerProps,
} from './FileExplorer'
import * as SideBar from './SideBar'
import { ConnectorState as SideBarConnectorState, Props as SideBarProps } from './SideBar'
export const FileExplorerCore: Sources<FileExplorerProps, FileExplorerConnectorState> = FileExplorer
export const SideBarCore: Sources<SideBarProps, SideBarConnectorState> = SideBar

4
src/global.d.ts vendored
View file

@ -1,11 +1,13 @@
type MetaData = {
userName: string
repoName: string
defaultBranchName: string
branchName: string
defaultBranchName?: string
type?: EnumString<'tree' | 'blob' | 'pull'>
}
type PartialMetaData = Omit<MakeOptional<MetaData, 'branchName'>, 'defaultBranchName'>
type TreeNode = {
name: string
contents?: TreeNode[]

View file

@ -239,9 +239,8 @@ export function getPath() {
const pathElement =
document.querySelector(blobPathElementSelector) ||
document.querySelector(folderPathElementSelector)?.nextElementSibling
if (!pathElement || !pathElement.querySelector('.js-repo-root')) {
raiseError(new Error(`Path element not found`))
return ''
if (!pathElement?.querySelector('.js-repo-root')) {
return []
}
const path = ((pathElement as HTMLDivElement).innerText || '')
.replace(/ \/ Jump to $/, '')
@ -249,6 +248,5 @@ export function getPath() {
.split('/')
.filter(Boolean)
.slice(1) // the first is the repo's name
.join('/')
return path
}

View file

@ -1,4 +1,3 @@
import { raiseError } from 'analytics'
import { GITHUB_OAUTH } from 'env'
import { Base64 } from 'js-base64'
import { platform } from 'platforms'
@ -83,26 +82,13 @@ export function isEnterprise() {
return !window.location.host.endsWith('github.com')
}
function getBranchName() {
const pathFromDOM = DOMHelper.getPath()
const pathAndBranchFromURL = URLHelper.parse().path.filter(Boolean).join('/')
if (pathAndBranchFromURL.endsWith(pathFromDOM)) {
return pathAndBranchFromURL
.slice(0, pathAndBranchFromURL.length - pathFromDOM.length)
.replace(/\/$/, '')
}
raiseError(new Error(`Parsed path not end with path from DOM`))
}
function resolvePageScope(defaultBranchName?: string) {
const parsed = URLHelper.parse()
switch (parsed.type) {
// case undefined:
// return `branch-${defaultBranchName}`
case 'blob':
case 'tree': {
// handle URLs like {user}/{repo}/tree/{sha|branch}, issue#131
const branchName = getBranchName()
// handle URLs like {user}/{repo}/{'tree'|'blob'}/{sha|branch}, issue #131
const branchName = DOMHelper.getCurrentBranch()
if (branchName && branchName !== defaultBranchName) return `branch-${branchName}`
break
}
@ -121,7 +107,7 @@ const pathSHAMap = new Map<string, string>()
export const GitHub: Platform = {
isEnterprise,
resolveMeta() {
resolvePartialMetaData() {
if (!DOMHelper.isInRepoPage()) {
return null
}
@ -150,14 +136,11 @@ export const GitHub: Platform = {
repoName,
type,
branchName,
defaultBranchName: type ? undefined : branchName,
}
return metaData
},
resolvePageScope,
async getDefaultBranchName({ userName, repoName }, accessToken) {
const data = await API.getRepoMeta(userName, repoName, accessToken)
return data.default_branch
return (await API.getRepoMeta(userName, repoName, accessToken)).default_branch
},
resolveUrlFromMetaData({ userName, repoName }) {
return {
@ -274,7 +257,11 @@ export const GitHub: Platform = {
return Boolean(URLHelper.isInPullPage())
},
getCurrentPath(branchName) {
return URLHelper.getCurrentPath(branchName)
if (URLHelper.parse().path.length) {
return DOMHelper.getPath()
} else {
return []
}
},
setOAuth(code) {
return API.OAuth(code)
@ -298,33 +285,6 @@ async function createPullFileResolver(userName: string, repoName: string, pullId
}
}
function findMissingFolders(nodes: TreeNode[]) {
const folders = new Set<string>()
const foundFolders = new Set<string>()
for (const node of nodes) {
let path = node.path
if (node.type === 'tree') foundFolders.add(path)
else {
while (true) {
// 'a/b' -> 'a'
// 'a' -> ''
path = path.substring(0, path.lastIndexOf('/'))
if (path === '') break
folders.add(path)
}
}
}
const missingFolders: string[] = []
for (const folder of folders) {
if (!foundFolders.has(folder)) {
missingFolders.push(folder)
}
}
return missingFolders
}
export function useGitHubAttachCopySnippetButton(copySnippetButton: boolean) {
const attachCopySnippetButton = React.useCallback(
function attachCopySnippetButton() {

View file

@ -77,7 +77,7 @@ export const Gitea: Platform = {
isEnterprise() {
return !window.location.host.endsWith('gitea.com')
},
resolveMeta() {
resolvePartialMetaData() {
if (!DOMHelper.isInRepoPage()) {
return null
}

View file

@ -73,7 +73,7 @@ export const Gitee: Platform = {
isEnterprise() {
return !window.location.host.endsWith('gitee.com')
},
resolveMeta() {
resolvePartialMetaData() {
if (!DOMHelper.isInRepoPage()) {
return null
}

View file

@ -2,7 +2,7 @@ export const dummyPlatformForTypeSafety: Platform = {
isEnterprise() {
return false
},
resolveMeta() {
resolvePartialMetaData() {
return null
},
getDefaultBranchName: dummyPlatformMethod,

View file

@ -1,11 +1,9 @@
import { dummyPlatformForTypeSafety } from './dummyPlatformForTypeSafety'
import { Gitee } from './Gitee'
import { Gitea } from './Gitea'
import { Gitee } from './Gitee'
import { GitHub } from './GitHub'
const platforms: {
[name: string]: Platform
} = {
const platforms = {
GitHub: GitHub,
Gitee: Gitee,
Gitea: Gitea,
@ -13,7 +11,7 @@ const platforms: {
function resolvePlatform() {
for (const platform of Object.values(platforms)) {
if (platform.resolveMeta()) return platform
if (platform.resolvePartialMetaData()) return platform
}
return dummyPlatformForTypeSafety
}

View file

@ -1,20 +1,20 @@
type Platform = {
isEnterprise(): boolean
// branch name might not be available when resolving from DOM and URL
resolveMeta(): MakeOptional<MetaData, 'branchName'> | null
resolvePageScope?(branchName?: string): string
resolvePartialMetaData(): PartialMetaData | null
// resolveMetaData(metaData: PartialMetaData, accessToken?: string): Async<MetaData>
getDefaultBranchName(
metaData: Pick<MetaData, 'userName' | 'repoName'>,
accessToken?: string,
): Promise<string>
resolveUrlFromMetaData(
metaData: MetaData,
metaData: Pick<MetaData, 'userName' | 'repoName'>,
): {
userUrl: string
repoUrl: string
}
getTreeData(
metaData: MetaData,
metaData: Pick<MetaData, 'userName' | 'repoName' | 'branchName'>,
path?: string,
recursive?: boolean,
accessToken?: string,

View file

@ -1,68 +0,0 @@
interface Window {
ResizeObserver: ResizeObserver
}
/**
* The ResizeObserver interface is used to observe changes to Element's content
* rect.
*
* It is modeled after MutationObserver and IntersectionObserver.
*/
interface ResizeObserver {
new (callback: ResizeObserverCallback): ResizeObserver
/**
* Adds target to the list of observed elements.
*/
observe: (target: Element) => void
/**
* Removes target from the list of observed elements.
*/
unobserve: (target: Element) => void
/**
* Clears both the observationTargets and activeTargets lists.
*/
disconnect: () => void
}
/**
* This callback delivers ResizeObserver's notifications. It is invoked by a
* broadcast active observations algorithm.
*/
interface ResizeObserverCallback {
(entries: ResizeObserverEntry[], observer: ResizeObserver): void
}
interface ResizeObserverEntry {
/**
* @param target The Element whose size has changed.
*/
new (target: Element): void
/**
* The Element whose size has changed.
*/
readonly target: Element
/**
* Element's content rect when ResizeObserverCallback is invoked.
*/
readonly contentRect: DOMRectReadOnly
}
interface DOMRectReadOnly {
// static fromRect(other: DOMRectInit | undefined): DOMRectReadOnly;
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly top: number
readonly right: number
readonly bottom: number
readonly left: number
toJSON: () => any
}

View file

@ -182,17 +182,30 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
left: 0px;
display: inline-flex;
align-items: center;
transition: left 0.25s linear;
&.hidden {
left: -40px;
}
.#{$name}-toggle-show-button {
@include icon-button;
background: transparent;
border: none;
padding: 0;
position: relative;
left: -8px;
&.error {
cursor: not-allowed;
}
.octoface-icon {
@include flex-center();
width: 32px;
height: 32px;
padding: 4px;
border-radius: 6px;
border: 1px solid var(--gitako-border-tertiary);
background: var(--gitako-bg-tertiary);
@ -202,33 +215,27 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
}
transition: all ease 0.3s;
font-size: 16px;
transform: translateX(0px);
transform: translateX(8px);
&:active,
&:hover {
transform: translateX(4px);
transform: translateX(12px);
}
}
.tentacle {
width: 40px;
height: 40px;
object-fit: contain;
transition: all ease 0.4s;
transform: translateX(-16px);
transform: translateX(-8px);
filter: drop-shadow(0 0 1px var(--gitako-bg-backdrop));
}
&:active,
&:hover {
.tentacle {
transform: translateX(-12px);
transform: translateX(-4px);
filter: drop-shadow(0 0 2px var(--gitako-bg-backdrop));
}
}
img {
filter: drop-shadow(0 0 1px var(--gitako-bg-backdrop));
transition: all ease 0.3s;
width: 90%;
height: 90%;
object-fit: contain;
}
}
.error-message {
@ -259,7 +266,8 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
color: var(--gitako-text-link);
}
.#{$name}-position-wrapper {
$resizeHandlerWidth: 1px;
.#{$name}-side-bar-body-wrapper {
position: fixed;
top: 0;
left: 0;
@ -267,11 +275,21 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
z-index: $minimal-z-index;
display: flex;
&.hidden {
@extend %hidden;
&.toggle-mode-persistent {
&.collapsed {
@extend %hidden;
}
}
.gitako-position-content {
&.toggle-mode-float {
left: 0;
transition: all 0.25s cubic-bezier(0.55, 0.06, 0.68, 0.19); // values from Chrome devtools
&.collapsed {
left: calc(0px - #{$resizeHandlerWidth} - var(--gitako-width));
}
}
.gitako-side-bar-body-wrapper-content {
width: var(--gitako-width);
}
}
@ -282,7 +300,7 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
user-select: none;
width: 0;
background: var(--gitako-bg-tertiary);
border-right: 1px solid var(--gitako-border-tertiary);
border-right: $resizeHandlerWidth solid var(--gitako-border-tertiary);
overflow: hidden;
box-sizing: content-box;
&:hover,
@ -310,10 +328,6 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
border-left: 1px solid var(--gitako-border-overlay);
overflow: hidden;
&.hidden {
@extend %hidden;
}
.octicon {
transition: transform 0.3s ease;
color: var(--gitako-icon-tertiary);
@ -350,6 +364,10 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
transition: background linear 0.3s;
}
&.active .octicon {
color: var(--gitako-icon-primary);
}
.action-icon {
color: var(--gitako-icon-tertiary);
width: 20px;
@ -359,15 +377,22 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
width: 100%;
height: 100%;
}
.Pin,
.Tab {
transform: rotateY(180deg);
}
}
}
.close-side-bar-button + .close-side-bar-button {
margin-left: 4px;
}
}
.#{$name}-side-bar-content {
display: flex;
flex: 1;
flex-direction: column;
max-height: calc(100vh - 34px); // temporary fix for layout issue occurred since Chrome v76
min-height: 0; // make content shrinkable
.header {
@ -378,7 +403,7 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
min-height: 62px; // GitHub header height if login
flex-shrink: 0;
padding: 8px 10px;
padding-right: $button-size + 8px * 2; // space for toggle button
padding-right: $button-size * 2 + 8px; // space for toggle buttons
background: var(--gitako-bg-tertiary);
.user-and-repo a {
@ -394,8 +419,7 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
}
}
.description {
border-top: 1px solid var(--gitako-border-overlay);
.description-area {
padding: 4px 10px;
}

View file

@ -0,0 +1,85 @@
import { SearchMode } from 'components/searchModes'
import { platform, platformName } from 'platforms'
import { storageHelper } from 'utils/storageHelper'
import { migrateConfig } from './migrations'
export type Config = {
sideBarWidth: number
shortcut: string | undefined
accessToken: string | undefined
compressSingletonFolder: boolean
copyFileButton: boolean
copySnippetButton: boolean
intelligentToggle: boolean | null // `null` stands for intelligent, boolean for sidebar open state
icons: 'rich' | 'dim' | 'native'
toggleButtonVerticalDistance: number
toggleButtonContent: 'logo' | 'octoface'
recursiveToggleFolder: 'shift' | 'alt'
searchMode: SearchMode
sidebarToggleMode: 'persistent' | 'float'
commentToggle: boolean
}
enum configKeys {
sideBarWidth = 'sideBarWidth',
shortcut = 'shortcut',
accessToken = 'accessToken',
compressSingletonFolder = 'compressSingletonFolder',
copyFileButton = 'copyFileButton',
copySnippetButton = 'copySnippetButton',
intelligentToggle = 'intelligentToggle',
icons = 'icons',
toggleButtonVerticalDistance = 'toggleButtonVerticalDistance',
toggleButtonContent = 'toggleButtonContent',
recursiveToggleFolder = 'recursiveToggleFolder',
searchMode = 'searchMode',
sidebarToggleMode = 'sidebarToggleMode',
commentToggle = 'commentToggle',
}
export const defaultConfigs: Config = {
sideBarWidth: 260,
shortcut: undefined,
accessToken: '',
compressSingletonFolder: true,
copyFileButton: true,
copySnippetButton: !(platformName === 'GitHub' && !platform.isEnterprise()), // false when on github.com
intelligentToggle: null,
icons: 'rich',
toggleButtonVerticalDistance: 124, // align with GitHub's navbar items
toggleButtonContent: 'logo',
recursiveToggleFolder: 'shift',
searchMode: 'fuzzy',
sidebarToggleMode: 'float',
commentToggle: true,
}
const configKeyArray = Object.values(configKeys)
function applyDefaultConfigs(configs: Partial<Config>) {
return configKeyArray.reduce((applied, key) => {
Object.assign(applied, { [key]: key in configs ? configs[key] : defaultConfigs[key] })
return applied
}, {} as Config)
}
export type VersionedConfig<SiteConfig> = Record<string, SiteConfig> & { configVersion: string }
// do NOT use platform name
const platformStorageKey = `platform_` + window.location.host.toLowerCase()
const prepareConfig = new Promise<void>(async resolve => {
await migrateConfig()
resolve()
})
async function get(): Promise<Config> {
await prepareConfig
const config = await storageHelper.get<Record<string, Config>>([platformStorageKey])
return applyDefaultConfigs(config?.[platformStorageKey] || {})
}
async function set(config: Config) {
return await storageHelper.set({ [platformStorageKey]: config })
}
export const configHelper = { get, set }

View file

@ -0,0 +1,23 @@
import { storageHelper } from 'utils/storageHelper'
import { Migration } from '.'
import { Storage } from '../../storageHelper'
export const migration: Migration = {
version: '1.0.1',
async migrate(version) {
const config: any | void = await storageHelper.get<Storage>([
'configVersion',
'sideBarWidth',
'shortcut',
'access_token',
'compressSingletonFolder',
'copyFileButton',
'copySnippetButton',
'intelligentToggle',
'icons',
])
if (config && (!('configVersion' in config) || config.configVersion < version)) {
await storageHelper.set({ platform_GitHub: config, configVersion: version })
}
},
}

View file

@ -0,0 +1,28 @@
import { storageHelper } from 'utils/storageHelper'
import { Migration } from '.'
import { Storage } from '../../storageHelper'
import { Config, VersionedConfig } from '../helper'
export const migration: Migration = {
version: '1.3.4',
async migrate(version) {
const config: any | void = await storageHelper.get<VersionedConfig<Config> & Storage>([
'configVersion',
'platform_undefined',
'platform_GitHub',
'platform_github.com',
])
if (
config &&
'configVersion' in config &&
config.configVersion < version &&
(config.platform_GitHub || config.platform_undefined) &&
!config['platform_github.com']
) {
await storageHelper.set({
['platform_github.com']: config.platform_GitHub || config.platform_undefined,
configVersion: version,
})
}
},
}

View file

@ -0,0 +1,34 @@
import { storageHelper } from 'utils/storageHelper'
import { Migration, onConfigOutdated } from '.'
export const migration: Migration = {
version: '2.6.0',
async migrate(version) {
type ConfigBeforeMigrate = {
access_token?: string
}
type ConfigAfterMigrate = {
accessToken?: string
}
await onConfigOutdated(version, async configs => {
for (const key of Object.keys(configs)) {
if (
typeof configs[key] === 'object' &&
configs[key] !== null &&
'access_token' in configs[key]
) {
const configBeforeMigrate: ConfigBeforeMigrate = configs[key]
const { access_token: accessToken, ...rest } = configBeforeMigrate
const configAfterMigrate: ConfigAfterMigrate = {
...rest,
accessToken,
}
await storageHelper.set({
[key]: configAfterMigrate,
})
}
}
})
},
}

View file

@ -0,0 +1,33 @@
import { storageHelper } from 'utils/storageHelper'
import { Migration, onConfigOutdated } from '.'
export const migration: Migration = {
version: '3.0.0',
async migrate(version) {
// disable copy snippet button for github.com
type ConfigBeforeMigrate = {
copySnippetButton: boolean
}
type ConfigAfterMigrate = {
copySnippetButton: boolean
}
await onConfigOutdated(version, async configs => {
const key = 'platform_github.com'
const config = configs[key]
if (typeof config === 'object' && config !== null && 'copySnippetButton' in config) {
const configBeforeMigrate: ConfigBeforeMigrate = config
const { copySnippetButton, ...rest } = configBeforeMigrate
if (copySnippetButton) {
const configAfterMigrate: ConfigAfterMigrate = {
...rest,
copySnippetButton: false,
}
await storageHelper.set({
[key]: configAfterMigrate,
})
}
}
})
},
}

View file

@ -0,0 +1,32 @@
import { storageHelper } from 'utils/storageHelper'
import { Storage } from '../../storageHelper'
import { migration as v1v0v1 } from './1.0.1'
import { migration as v1v3v4 } from './1.3.4'
import { migration as v2v6v0 } from './2.6.0'
import { migration as v3v0v0 } from './3.0.0'
export type Migration = {
version: string
migrate(version: Migration['version']): Async<void>
}
export async function migrateConfig() {
const migrations: Migration[] = [v1v0v1, v1v3v4, v2v6v0, v3v0v0]
for (const { version, migrate } of migrations) {
await migrate(version)
}
}
export async function onConfigOutdated<T extends { [key: string]: any }>(
configVersion: string,
runIfOutdated: (config: T) => Async<void>,
) {
const config = await storageHelper.get<Storage>()
if (config && config.configVersion < configVersion) {
const { configVersion, ...restConfig } = config
await runIfOutdated(restConfig as T)
await storageHelper.set({ configVersion })
}
}

View file

@ -1,179 +0,0 @@
import { SearchMode } from 'components/searchModes'
import * as storageHelper from 'utils/storageHelper'
export type Config = {
sideBarWidth: number
shortcut: string | undefined
accessToken: string | undefined
compressSingletonFolder: boolean
copyFileButton: boolean
copySnippetButton: boolean
intelligentToggle: boolean | null // `null` stands for intelligent, boolean for sidebar open state
icons: 'rich' | 'dim' | 'native'
toggleButtonVerticalDistance: number
toggleButtonContent: 'logo' | 'octoface'
recursiveToggleFolder: 'shift' | 'alt'
searchMode: SearchMode
commentToggle: boolean
}
enum configKeys {
sideBarWidth = 'sideBarWidth',
shortcut = 'shortcut',
accessToken = 'accessToken',
compressSingletonFolder = 'compressSingletonFolder',
copyFileButton = 'copyFileButton',
copySnippetButton = 'copySnippetButton',
intelligentToggle = 'intelligentToggle',
icons = 'icons',
toggleButtonVerticalDistance = 'toggleButtonVerticalDistance',
toggleButtonContent = 'toggleButtonContent',
recursiveToggleFolder = 'recursiveToggleFolder',
searchMode = 'searchMode',
commentToggle = 'commentToggle',
}
const defaultConfigs: Config = {
sideBarWidth: 260,
shortcut: undefined,
accessToken: '',
compressSingletonFolder: true,
copyFileButton: true,
copySnippetButton: true,
intelligentToggle: null,
icons: 'rich',
toggleButtonVerticalDistance: 124, // align with GitHub's navbar items
toggleButtonContent: 'logo',
recursiveToggleFolder: 'shift',
searchMode: 'fuzzy',
commentToggle: true,
}
const configKeyArray = Object.values(configKeys)
function applyDefaultConfigs(configs: Partial<Config>) {
return configKeyArray.reduce((applied, key) => {
Object.assign(applied, { [key]: key in configs ? configs[key] : defaultConfigs[key] })
return applied
}, {} as Config)
}
type VersionedConfig<SiteConfig> = Record<string, SiteConfig> & { configVersion: string }
type Storage = {
// save root level `configVersion` for easier future migrating
[key in 'configVersion' | string]: string
// separate different platform configs to simplify interactions with browser storage API
// e.g.
// platform_github.com?: Config
}
async function migrateConfig() {
// not referencing to enum above to prevent migrate future configs
const migrations: {
version: string
migrate(version: string): Promise<void>
}[] = [
{
version: '1.0.1',
async migrate(version) {
const config: any | void = await storageHelper.get<Storage>([
'configVersion',
'sideBarWidth',
'shortcut',
'access_token',
'compressSingletonFolder',
'copyFileButton',
'copySnippetButton',
'intelligentToggle',
'icons',
'commentToggle',
])
if (config && (!('configVersion' in config) || config.configVersion < version)) {
await storageHelper.set({ platform_GitHub: config, configVersion: version })
}
},
},
{
version: '1.3.4',
async migrate(version) {
const config: any | void = await storageHelper.get<VersionedConfig<Config> & Storage>([
'configVersion',
'platform_undefined', // this was a mistake :(
'platform_GitHub',
'platform_github.com',
])
if (
config &&
'configVersion' in config &&
config.configVersion < version &&
(config.platform_GitHub || config.platform_undefined) &&
!config['platform_github.com']
) {
await storageHelper.set({
['platform_github.com']: config.platform_GitHub || config.platform_undefined,
configVersion: version,
})
}
},
},
{
version: '2.6.0',
async migrate(version) {
type LegacySiteConfig = {
access_token?: string
}
type MigratedSiteConfig = {
accessToken?: string
}
const config = await storageHelper.get<VersionedConfig<LegacySiteConfig> & Storage>()
if (config && config.configVersion < version) {
const { configVersion, ...restConfig } = config
for (const key of Object.keys(restConfig)) {
if (
typeof restConfig[key] === 'object' &&
restConfig[key] &&
'access_token' in restConfig[key]
) {
const config: LegacySiteConfig = restConfig[key]
const { access_token: accessToken, ...legacy } = config
const migrated: MigratedSiteConfig = {
...legacy,
accessToken,
}
await storageHelper.set({
[key]: migrated,
})
}
}
await storageHelper.set({
configVersion: version,
})
}
},
},
]
for (const { version, migrate } of migrations) {
await migrate(version)
}
}
// do NOT use platform name
const platformStorageKey = `platform_` + window.location.host.toLowerCase()
const prepareConfig = new Promise(async resolve => {
await migrateConfig()
resolve()
})
export async function get(): Promise<Config> {
await prepareConfig
const config = await storageHelper.get<Record<string, Config>>([platformStorageKey])
return applyDefaultConfigs(config?.[platformStorageKey] || {})
}
export async function set(config: Config) {
return await storageHelper.set({ [platformStorageKey]: config })
}

View file

@ -1,18 +1,28 @@
type CXValue = string | boolean | null | undefined
/**
* cx('class1', { class2: true, class3: false }) --> 'class1 class2'
*/
export function cx(...classNames: any[]): string {
return classNames
.filter(Boolean)
.map(className => {
switch (typeof className) {
case 'string':
return className
case 'object':
return cx(...Object.entries(className).map(([key, value]) => (value ? key : null)))
default:
return ''
export function cx(
...classNames: (
| CXValue
| {
[className: string]: CXValue
}
})
.join(' ')
)[]
): string {
return classNames.map(handleClassName).filter(Boolean).join(' ')
}
function handleClassName(className: Parameters<typeof cx>[number]): string | false {
switch (typeof className) {
case 'string':
return className
case 'object':
return className === null
? false
: cx(...Object.entries(className).map(([key, value]) => (value ? key : false)))
default:
return false
}
}

View file

@ -105,23 +105,8 @@ export function setStyleSheetMedia(style: HTMLStyleElement, media: string) {
style.setAttribute('media', media)
}
export function parseURLSearch(search: string = window.location.search) {
const parsed: any = {}
if (search.startsWith('?')) {
const pairs = search
.slice(1)
.split('&')
.map(rawPair => rawPair.split('=').map(raw => decodeURIComponent(raw))) // [key, value?][]
pairs.forEach(([key, value]) => {
if (Object.prototype.hasOwnProperty.call(parsed, key)) {
if (Array.isArray(parsed[key])) parsed[key].push(value)
else parsed[key] = [parsed[key], value]
} else {
parsed[key] = value
}
})
}
return parsed
export function parseURLSearch(search = window.location.search) {
return new URLSearchParams(search)
}
export async function JSONRequest(url: string, data: any, extra: RequestInit = { method: 'post' }) {

View file

@ -0,0 +1,45 @@
import { useConfigs } from 'containers/ConfigsContext'
import { errors, platformName } from 'platforms'
import { useCallback } from 'react'
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
import { SideBarErrorContext } from '../../containers/ErrorContext'
import { SideBarStateContext } from '../../containers/SideBarState'
export function useCatchNetworkError() {
const { accessToken } = useConfigs().value
const stateContext = useLoadedContext(SideBarStateContext)
const errorContext = useLoadedContext(SideBarErrorContext)
return useCallback(
async function <T>(fn: () => T) {
try {
return await fn() // keep the await so that catch block can catch async errors
} catch (err) {
if (err.message === errors.EMPTY_PROJECT) {
errorContext.onChange('This project seems to be empty.')
} else if (err.message === errors.BLOCKED_PROJECT) {
errorContext.onChange('Access to the project is blocked.')
} else if (
err.message === errors.NOT_FOUND ||
err.message === errors.BAD_CREDENTIALS ||
err.message === errors.API_RATE_LIMIT
) {
stateContext.onChange('error-due-to-auth')
} else if (err.message === errors.CONNECTION_BLOCKED) {
if (accessToken) {
errorContext.onChange(`Cannot connect to ${platformName}.`)
} else {
stateContext.onChange('error-due-to-auth')
}
} else if (err.message === errors.SERVER_FAULT) {
errorContext.onChange(`${platformName} server went down.`)
} else {
stateContext.onChange('disabled')
errorContext.onChange('Something unexpected happened.')
throw err
}
}
},
[accessToken /* , stateContext.value, errorContext.value */],
)
}

View file

@ -0,0 +1,9 @@
import * as React from 'react'
export function useEffectOnSerializableUpdates<T>(
value: T,
serialize: (value: T) => string,
onChange: (value: T) => void,
) {
React.useEffect(() => onChange(value), [onChange, serialize(value)])
}

View file

@ -0,0 +1,7 @@
import * as React from 'react'
export function useLoadedContext<T>(context: React.Context<T | null>): T {
const ctx = React.useContext(context)
if (ctx === null) throw new Error(`Context not loaded`)
return ctx
}

View file

@ -0,0 +1,27 @@
import { ConfigsContextShape } from 'containers/ConfigsContext'
import * as React from 'react'
import * as keyHelper from 'utils/keyHelper'
import { SideBarState } from '../../containers/SideBarState'
export function useToggleSideBarWithKeyboard(
state: SideBarState,
configContext: ConfigsContextShape,
toggleShowSideBar: () => void,
) {
const isDisabled = state === 'disabled'
React.useEffect(
function attachKeyDown() {
if (isDisabled || !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, isDisabled, configContext.value.shortcut],
)
}

View file

@ -1,6 +1,15 @@
const localStorage = browser.storage.local
export async function get<
export type Storage = {
// save root level `configVersion` for easier future migrating
[key in EnumString<'configVersion'>]: string
// separate different platform configs to simplify interactions with browser storage API
// e.g.
// ['platform_github.com']?: Config
}
async function get<
T extends {
[key: string]: any
}
@ -10,8 +19,10 @@ export async function get<
} catch (err) {}
}
export function set(value: any): Promise<void> | void {
function set(value: any): Promise<void> | void {
try {
return localStorage.set(value)
} catch (err) {}
}
export const storageHelper = { get, set }

View file

@ -7,6 +7,7 @@
"strict": true,
"lib": ["dom", "es2017.object", "es2016", "ES2020.String"],
"baseUrl": "src",
"allowSyntheticDefaultImports": true,
"outDir": "dist"
},
"include": ["src"]

226
yarn.lock
View file

@ -1428,68 +1428,68 @@
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.3.0.tgz#2098181aab751f2275cacf219b02a6a8b455be85"
integrity sha512-dQA1acyNpwqy5ia5yt1lNjaAhm9rrzSurFtlJPoz7ARVPMV1yZ0yfmNotMwwgVbE5q1HnONqszK7oagH86m7Qw==
"@sentry/browser@^5.12.1":
version "5.12.1"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.12.1.tgz#dc1f268595269fb7277f55eb625c7e92d76dc01b"
integrity sha512-Zl7VdppUxctyaoqMSEhnDJp2rrupx8n8N2n3PSooH74yhB2Z91nt84mouczprBsw3JU1iggGyUw9seRFzDI1hw==
"@sentry/browser@^6.3.6":
version "6.3.6"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.3.6.tgz#bba07033efded6c844de88dcc47f99548a29afed"
integrity sha512-l4323jxuBOArki6Wf+EHes39IEyJ2Zj/CIUaTY7GWh7CntpfHQAfFmZWQw3Ozq+ka1u8lVp25RPhb4Wng3azNA==
dependencies:
"@sentry/core" "5.12.0"
"@sentry/types" "5.12.0"
"@sentry/utils" "5.12.0"
"@sentry/core" "6.3.6"
"@sentry/types" "6.3.6"
"@sentry/utils" "6.3.6"
tslib "^1.9.3"
"@sentry/cli@^1.51.0":
version "1.51.0"
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.51.0.tgz#2ce7d03b7aebf3f9e9a7186bac1a777fc3dc0e82"
integrity sha512-QXdW3smFW9Wjd5gYHuA9u9tCra87VqpeFljRKdD7D2CwnYCnFDeluVk3l9O8Me6IoVBuL9Uwxx8q1vPtob4n3Q==
"@sentry/cli@^1.64.2":
version "1.64.2"
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.64.2.tgz#38a68d31692675061fdedd27d4006a249bc915a9"
integrity sha512-hBF8cswn878W7YqAgeg/vcQ51BgtpeiklGV6Q5MEXqGXtbjaKTBYBcKTkhzpkkcUtTu5bFkVcdGzVtpl4oBe+g==
dependencies:
fs-copy-file-sync "^1.1.1"
https-proxy-agent "^4.0.0"
mkdirp "^0.5.1"
node-fetch "^2.1.2"
progress "2.0.0"
proxy-from-env "^1.0.0"
https-proxy-agent "^5.0.0"
mkdirp "^0.5.5"
node-fetch "^2.6.0"
npmlog "^4.1.2"
progress "^2.0.3"
proxy-from-env "^1.1.0"
"@sentry/core@5.12.0":
version "5.12.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.12.0.tgz#d6380c4ef7beee5f418ac1d0e5be86a2de2af449"
integrity sha512-wY4rsoX71QsGpcs9tF+OxKgDPKzIFMRvFiSRcJoPMfhFsTilQ/CBMn/c3bDtWQd9Bnr/ReQIL6NbnIjUsPHA4Q==
"@sentry/core@6.3.6":
version "6.3.6"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.3.6.tgz#e2ec6ae7e456e61f28000bab2d8ce85f58c59c66"
integrity sha512-w6BRizAqh7BaiM9oeKzO6aACXwRijUPacYaVLX/OfhqCSueF9uDxpMRT7+4D/eCeDVqgJYhBJ4Vsu2NSstkk4A==
dependencies:
"@sentry/hub" "5.12.0"
"@sentry/minimal" "5.12.0"
"@sentry/types" "5.12.0"
"@sentry/utils" "5.12.0"
"@sentry/hub" "6.3.6"
"@sentry/minimal" "6.3.6"
"@sentry/types" "6.3.6"
"@sentry/utils" "6.3.6"
tslib "^1.9.3"
"@sentry/hub@5.12.0":
version "5.12.0"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.12.0.tgz#5e8c8f249f5bdbeb8cc4ec02c2ccc53a67f2cc02"
integrity sha512-3k7yE8BEVJsKx8mR4LcI4IN0O8pngmq44OcJ/fRUUBAPqsT38jsJdP2CaWhdlM1jiNUzUDB1ktBv6/lY+VgcoQ==
"@sentry/hub@6.3.6":
version "6.3.6"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.3.6.tgz#e7bc6960e30d8731e23c6e77f31af0bfb1d5af3c"
integrity sha512-foBZ3ilMnm9Gf9OolrAxYHK8jrA6IF72faDdJ3Al+1H27qcpnBaMdrdEp2/jzwu/dgmwuLmbBaMjEPXaGH/0JQ==
dependencies:
"@sentry/types" "5.12.0"
"@sentry/utils" "5.12.0"
"@sentry/types" "6.3.6"
"@sentry/utils" "6.3.6"
tslib "^1.9.3"
"@sentry/minimal@5.12.0":
version "5.12.0"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.12.0.tgz#2611e2aa520c1edb7999e6de51bd65ec66341757"
integrity sha512-fk73meyz4k4jCg9yzbma+WkggsfEIQWI2e2TWfYsRGcrV3RnlSrXyM4D91/A8Bjx10SNezHPUFHjasjlHXOkyA==
"@sentry/minimal@6.3.6":
version "6.3.6"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.3.6.tgz#aebcebd2ee9007b0ec505b9fcefd10f10fc5d43d"
integrity sha512-uM2/dH0a6zfvI5f+vg+/mST+uTBdN6Jgpm585ipH84ckCYQwIIDRg6daqsen4S1sy/xgg1P1YyC3zdEC4G6b1Q==
dependencies:
"@sentry/hub" "5.12.0"
"@sentry/types" "5.12.0"
"@sentry/hub" "6.3.6"
"@sentry/types" "6.3.6"
tslib "^1.9.3"
"@sentry/types@5.12.0":
version "5.12.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.12.0.tgz#5367e53c74261beea01502e3f7b6f3d822682a31"
integrity sha512-aZbBouBLrKB8wXlztriIagZNmsB+wegk1Jkl6eprqRW/w24Sl/47tiwH8c5S4jYTxdAiJk+SAR10AAuYmIN3zg==
"@sentry/types@6.3.6":
version "6.3.6"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.3.6.tgz#aa3687051af1dc04ebc4eaf7f9562872da67aa5c"
integrity sha512-93cFJdJkWyCfyZeWFARSU11qnoHVOS/R2h5WIsEf+jbQmkqG2C+TXVz/19s6nHVsfDrwpvYpwALPv4/nrxfU7g==
"@sentry/utils@5.12.0":
version "5.12.0"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.12.0.tgz#62967f934a3ee6d21472eac0219084e37225933e"
integrity sha512-fYUadGLbfTCbs4OG5hKCOtv2jrNE4/8LHNABy9DwNJ/t5DVtGqWAZBnxsC+FG6a3nVqCpxjFI9AHlYsJ2wsf7Q==
"@sentry/utils@6.3.6":
version "6.3.6"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.3.6.tgz#6f619a525f2a94fa6b160500f63f4bd5bd171055"
integrity sha512-HnYlDBf8Dq8MEv7AulH7B6R1D/2LAooVclGdjg48tSrr9g+31kmtj+SAj2WWVHP9+bp29BWaC7i5nkfKrOibWw==
dependencies:
"@sentry/types" "5.12.0"
"@sentry/types" "6.3.6"
tslib "^1.9.3"
"@sheerun/mutationobserver-shim@^0.3.2":
@ -1759,6 +1759,11 @@
resolved "https://registry.yarnpkg.com/@types/js-base64/-/js-base64-2.3.1.tgz#c39f14f129408a3d96a1105a650d8b2b6eeb4168"
integrity sha512-4RKbhIDGC87s4EBy2Cp2/5S2O6kmCRcZnD5KRCq1q9z2GhBte1+BdsfVKCpG8yKpDGNyEE2G6IqFIh6W2YwWPA==
"@types/js-cookie@2.2.6":
version "2.2.6"
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f"
integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==
"@types/minimatch@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@ -2044,6 +2049,11 @@
"@webassemblyjs/wast-parser" "1.8.5"
"@xtuc/long" "4.2.2"
"@xobotyi/scrollbar-width@1.9.5":
version "1.9.5"
resolved "https://registry.yarnpkg.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d"
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@ -2209,6 +2219,13 @@ agent-base@5:
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-5.1.1.tgz#e8fb3f242959db44d63be665db7a8e739537a32c"
integrity sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==
agent-base@6:
version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
dependencies:
debug "4"
ajv-errors@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d"
@ -4898,6 +4915,11 @@ fast-deep-equal@^3.1.1:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4"
integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==
fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-json-patch@^2.0.6:
version "2.2.1"
resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-2.2.1.tgz#18150d36c9ab65c7209e7d4eb113f4f8eaabe6d9"
@ -4925,6 +4947,11 @@ fast-safe-stringify@^2.0.7:
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==
fast-shallow-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b"
integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==
fastest-stable-stringify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028"
@ -5222,11 +5249,6 @@ fs-constants@^1.0.0:
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs-copy-file-sync@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/fs-copy-file-sync/-/fs-copy-file-sync-1.1.1.tgz#11bf32c096c10d126e5f6b36d06eece776062918"
integrity sha512-2QY5eeqVv4m2PfyMiEuy9adxNP+ajf+8AR05cEi+OAzPcOj90hvFImeZhTmKLBgSd9EvG33jsD7ZRxsx9dThkQ==
fs-exists-sync@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
@ -5776,6 +5798,14 @@ https-proxy-agent@^4.0.0:
agent-base "5"
debug "4"
https-proxy-agent@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
dependencies:
agent-base "6"
debug "4"
human-signals@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
@ -6758,6 +6788,11 @@ js-base64@^2.5.1:
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.1.tgz#1efa39ef2c5f7980bb1784ade4a8af2de3291121"
integrity sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==
js-cookie@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"
integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==
js-levenshtein@^1.1.3:
version "1.1.6"
resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d"
@ -7500,7 +7535,7 @@ mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1:
dependencies:
minimist "0.0.8"
mkdirp@^0.5.3:
mkdirp@^0.5.3, mkdirp@^0.5.5:
version "0.5.5"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
@ -7658,12 +7693,7 @@ nice-try@^1.0.4:
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
node-fetch@^2.1.2:
version "2.6.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-fetch@^2.6.1:
node-fetch@^2.6.0, node-fetch@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
@ -7829,7 +7859,7 @@ npm-run-path@^4.0.0:
dependencies:
path-key "^3.0.0"
npmlog@^4.0.2:
npmlog@^4.0.2, npmlog@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
@ -8492,17 +8522,12 @@ process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
progress@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
integrity sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=
progress@^1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be"
integrity sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=
progress@^2.0.0, progress@^2.0.1:
progress@^2.0.0, progress@^2.0.1, progress@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
@ -8542,6 +8567,11 @@ proxy-from-env@^1.0.0:
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee"
integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
@ -8715,19 +8745,14 @@ react-clientside-effect@^1.2.2:
dependencies:
"@babel/runtime" "^7.0.0"
react-dom@^17.0.1:
version "17.0.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6"
integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler "^0.20.1"
react-fast-compare@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
scheduler "^0.20.2"
react-focus-lock@^2.1.0:
version "2.2.1"
@ -8783,20 +8808,30 @@ react-style-singleton@^2.0.0:
invariant "^2.2.4"
tslib "^1.0.0"
react-use@^13.8.0:
version "13.8.0"
resolved "https://registry.yarnpkg.com/react-use/-/react-use-13.8.0.tgz#5e8badaaa5614a1925fd28ad22d01cc1c52e0ff1"
integrity sha512-J4ZWIqC1h2eSS6ObmHJUWDQeJSKZvHdBEINCu51plz7RS+ijrt6JEHfDWbNXx+aAoR5AmbRK+mzYwd3jiu3xpA==
react-universal-interface@^0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/react-universal-interface/-/react-universal-interface-0.6.2.tgz#5e8d438a01729a4dbbcbeeceb0b86be146fe2b3b"
integrity sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==
react-use@^15.3.0:
version "15.3.8"
resolved "https://registry.yarnpkg.com/react-use/-/react-use-15.3.8.tgz#ca839ac7fb3d696e5ccbeabbc8dadc2698969d30"
integrity sha512-GeGcrmGuUvZrY5wER3Lnph9DSYhZt5nEjped4eKDq8BRGr2CnLf9bDQWG9RFc7oCPphnscUUdOovzq0E5F2c6Q==
dependencies:
"@types/js-cookie" "2.2.6"
"@xobotyi/scrollbar-width" "1.9.5"
copy-to-clipboard "^3.2.0"
fast-deep-equal "^3.1.3"
fast-shallow-equal "^1.0.0"
js-cookie "^2.2.1"
nano-css "^5.2.1"
react-fast-compare "^2.0.4"
react-universal-interface "^0.6.2"
resize-observer-polyfill "^1.5.1"
screenfull "^5.0.0"
set-harmonic-interval "^1.0.1"
throttle-debounce "^2.1.0"
ts-easing "^0.2.0"
tslib "^1.10.0"
tslib "^2.0.0"
react-window@^1.8.5:
version "1.8.5"
@ -8807,9 +8842,9 @@ react-window@^1.8.5:
memoize-one ">=3.1.1 <6"
react@^16.10.2, react@^17, react@^17.0.1:
version "17.0.1"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127"
integrity sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@ -9354,10 +9389,10 @@ sax@>=0.6.0, sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.20.1:
version "0.20.1"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c"
integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw==
scheduler@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@ -10471,11 +10506,16 @@ tslib@^1.0.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
tslib@^1.10.0, tslib@^1.9.0, tslib@^1.9.3:
tslib@^1.9.0, tslib@^1.9.3:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
tslib@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==
tty-browserify@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@ -10540,10 +10580,10 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
typescript@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.3.tgz#153bbd468ef07725c1df9c77e8b453f8d36abba5"
integrity sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg==
typescript@^4.2.4:
version "4.2.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
uglify-js@^3.6.0:
version "3.6.0"