Merge remote-tracking branch 'origin/develop' into next

# Conflicts:
#	src/analytics.ts
#	src/components/FileExplorer.tsx
#	src/components/SideBarBodyWrapper.tsx
#	src/components/settings/SettingsBar.tsx
#	src/content.tsx
#	src/platforms/GitHub/API.ts
#	src/platforms/GitHub/DOMHelper.ts
#	src/platforms/GitHub/index.ts
#	src/platforms/platform.d.ts
#	src/styles/index.scss
#	src/utils/DOMHelper.ts
#	src/utils/config/helper.ts
#	src/utils/config/migrations/1.3.4.ts
#	src/utils/config/migrations/index.ts
#	src/utils/general.ts
#	src/utils/storageHelper.ts
This commit is contained in:
EnixCoda 2022-07-03 16:23:43 +08:00
commit d6e9fcc163
24 changed files with 328 additions and 104 deletions

View file

@ -6,7 +6,7 @@ describe(`in Gitako project page`, () => {
it('should not break go back in history', async () => {
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/"]`,
`main .TimelineItem-body ol li > div:nth-child(1) a[href*="/commit/"]`,
)
if (commitLinks.length < 2) throw new Error(`No enough commits`)
commitLinks[i].click()

View file

@ -7,7 +7,7 @@ import {
} from '../../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako'))
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/tree/test/200-changed-files-200-lines-each'))
it('should render Gitako', async () => {
await expectToFind('.gitako-side-bar .gitako-side-bar-body-wrapper')
@ -22,11 +22,11 @@ describe(`in Gitako project page`, () => {
const filesEle = await page.waitForSelector('.gitako-side-bar .files')
// node of tsconfig.json should NOT be rendered before scroll down
await expectToNotFind(selectFileTreeItem('package.json'))
await expectToNotFind(selectFileTreeItem('tsconfig.json'))
const box = await filesEle?.boundingBox()
if (box) {
await page.mouse.move(box.x + 40, box.y + 40)
await scroll({ totalDistance: 200, duration: 1000 })
await scroll({ totalDistance: 7000, stepDistance: 100 })
// node of tsconfig.json should be rendered now
await expectToFind(selectFileTreeItem('tsconfig.json'))

View file

@ -12,17 +12,14 @@ export function sleep(timeout: number) {
export async function scroll({
totalDistance,
step = 1,
duration = 500,
stepDistance = 100,
}: {
totalDistance: number
step?: number
duration?: number
stepDistance?: number
}) {
let distance = 0
while ((distance += step) < totalDistance) {
await (page.mouse as any).wheel({ deltaY: step })
await sleep((duration * step) / totalDistance)
while ((distance += stepDistance) < totalDistance) {
await (page.mouse as any).wheel({ deltaY: stepDistance })
}
}

View file

@ -1,6 +1,6 @@
{
"name": "gitako",
"version": "3.7.0",
"version": "3.7.4",
"description": "File tree for GitHub, and more than that.",
"repository": "https://github.com/EnixCoda/Gitako",
"author": "EnixCoda",

View file

@ -1,12 +1,13 @@
import * as Sentry from '@sentry/browser'
import { IN_PRODUCTION_MODE, VERSION } from 'env'
import { platform } from 'platforms'
import { forOf } from 'utils/general'
import { atomicAsyncFunction, forOf } from 'utils/general'
import { storageHelper, storageKeys } from 'utils/storageHelper'
const PUBLIC_KEY = 'd22ec5c9cc874539a51c78388c12e3b0'
const PROJECT_ID = '1406497'
const MAX_REPORT_COUNT = 10 // protect for error leaking
const MAX_REPORT_COUNT = 10 // prevent error overflow
let countReportedError = 0
const errorSet = new Set<string>([
@ -56,7 +57,45 @@ const sentryOptions: Sentry.BrowserOptions = {
}
Sentry.init(sentryOptions)
export function raiseError(error: Error, extra?: unknown) {
// 1. Only cache errors for current version, so that future errors can still be exposed
// - Run migration to clean on every update
// 2. Only cache the top 2 levels of stack, e.g.
// ```
// Error: cannot get current branch
// at Module.getCurrentBranch (chrome-extension://______id______/index.js:1:1)"
// ```
// So that different initial callees would not result in multiple records
const MAX_STACK_LEVEL = 2
const hasTheErrorBeenReported = atomicAsyncFunction(async function hasTheErrorBeenReported(
error: Error,
) {
const message = error.stack?.split('\n').slice(0, MAX_STACK_LEVEL).join('\n')
if (!message) return true // ignore errors that has no stack
type ErrorCache = string
const cache: ErrorCache[] =
((await storageHelper.get(storageKeys.raiseErrorCache))?.[
storageKeys.raiseErrorCache
] as string[]) || []
const has = cache.includes(message)
if (!has) {
cache.push(message)
await storageHelper.set({ [storageKeys.raiseErrorCache]: cache })
}
return has
})
export async function raiseError(
error: Error,
extra?: {
[key: string]: any
},
) {
if (await hasTheErrorBeenReported(error)) return
if (!IN_PRODUCTION_MODE || platform.isEnterprise()) {
// ignore errors from enterprise to get less noise on Sentry
console.error(error)
@ -66,7 +105,7 @@ export function raiseError(error: Error, extra?: unknown) {
Sentry.withScope(scope => {
if (typeof extra === 'object' && extra) {
forOf(extra, (key, value) => scope.setExtra(key, value))
forOf(extra, (key, value) => scope.setExtra(`${key}`, value))
}
Sentry.captureException(error)
})

View file

@ -1,7 +1,7 @@
import { CommentIcon } from '@primer/octicons-react'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { isNotFalsy } from 'utils/general'
import { is } from 'utils/is'
import { Icon } from '../../Icon'
import { SearchMode } from '../../searchModes'
import { DiffStatGraph } from './../DiffStatGraph'
@ -10,7 +10,7 @@ export type NodeRenderer = (node: TreeNode) => React.ReactNode
export function useNodeRenderers(allRenderers: (NodeRenderer | null | undefined)[]) {
return React.useMemo(() => {
const renderers: NodeRenderer[] = allRenderers.filter(isNotFalsy)
const renderers: NodeRenderer[] = allRenderers.filter(is.not.nil)
return renderers.length
? (node: TreeNode) =>
renderers.map((render, i) => <React.Fragment key={i}>{render(node)}</React.Fragment>)
@ -43,7 +43,9 @@ export function useRenderFileCommentAmounts() {
node.comments.active
} active, ${node.comments.resolved} resolved`}
>
<Icon IconComponent={CommentIcon} /> {node.comments.active > 9 ? '9+' : node.comments.active}
<Icon IconComponent={CommentIcon} />
&nbsp;
{node.comments.active > 9 ? '9+' : node.comments.active}
</span>
) : null
}

View file

@ -60,7 +60,7 @@ export function SideBar() {
: intelligentToggle,
)
const shouldShow = $shouldShow.value
React.useEffect(() => {
const toggleBodyIndent = React.useCallback(() => {
if (sidebarToggleMode === 'persistent') {
DOMHelper.setBodyIndent(shouldShow)
} else {
@ -72,6 +72,12 @@ export function SideBar() {
}
}, [shouldShow, sidebarToggleMode])
React.useEffect(() => {
toggleBodyIndent()
}, [toggleBodyIndent])
useOnPJAXDone(toggleBodyIndent)
// Save expand state on toggle if auto expand is off
React.useEffect(() => {
if (intelligentToggle !== null) {
@ -105,12 +111,11 @@ export function SideBar() {
}
}, [intelligentToggle, sidebarToggleMode, setShowSideBar])
usePJAX()
useOnPJAXDone(updateSideBarVisibility)
platform.usePlatformHooks?.()
usePJAX()
// Hide sidebar when error due to auth but token is set #128
const { accessToken } = configContext.value
const hideSidebarOnInvalidToken: boolean =

View file

@ -2,7 +2,7 @@ import { ResizeHandler } 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 { getDefaultConfigs } from 'utils/config/helper'
import { cx } from 'utils/cx'
import { setCSSVariable } from 'utils/DOMHelper'
import * as features from 'utils/features'
@ -112,6 +112,8 @@ export function SideBarBodyWrapper({
blockLeaveRef.current = state === 'resizing'
}, [])
const defaultSideBarWidth = React.useMemo(() => getDefaultConfigs().sideBarWidth, []);
return (
<div
ref={bodyWrapperRef}
@ -124,8 +126,8 @@ export function SideBarBodyWrapper({
<ResizeHandler
onResize={onResize}
onResetSize={() => {
setSize(defaultConfigs.sideBarWidth)
applySizeToCSSVariables(sizeVariableMountPoint, defaultConfigs.sideBarWidth)
setSize(defaultSideBarWidth)
applySizeToCSSVariables(sizeVariableMountPoint, defaultSideBarWidth)
}}
onResizeStateChange={onResizeStateChange}
size={dummySize}

View file

@ -2,9 +2,11 @@ import { ChevronDownIcon } from '@primer/octicons-react'
import { Box } from '@primer/react'
import { Footer } from 'components/Footer'
import { RoundIconButton } from 'components/RoundIconButton'
import { useConfigs } from 'containers/ConfigsContext'
import { platform } from 'platforms'
import { GitHub } from 'platforms/GitHub'
import * as React from 'react'
import { useUpdateEffect } from 'react-use'
import { useStateIO } from 'utils/hooks/useStateIO'
import { AccessTokenSettings } from './AccessTokenSettings'
import { FileTreeSettings } from './FileTreeSettings'
@ -21,9 +23,12 @@ export const wikiLinks = {
copyFileButton: `${WIKI_HOME_LINK}/Copy-file-and-snippet`,
copySnippet: `${WIKI_HOME_LINK}/Copy-file-and-snippet`,
createAccessToken: `${WIKI_HOME_LINK}/Access-token-for-Gitako`,
pjaxMode: `${WIKI_HOME_LINK}/Pjax-Mode`,
}
const moreFields: SimpleConfigField<'copyFileButton' | 'copySnippetButton' | 'codeFolding'>[] =
const moreFields: SimpleConfigField<
'copyFileButton' | 'copySnippetButton' | 'codeFolding' | 'pjaxMode'
>[] =
platform === GitHub
? [
{
@ -32,6 +37,16 @@ const moreFields: SimpleConfigField<'copyFileButton' | 'copySnippetButton' | 'co
wikiLink: wikiLinks.codeFolding,
tooltip: `Read more in Gitako's Wiki`,
},
{
key: 'pjaxMode',
label: 'Native PJAX mode',
wikiLink: wikiLinks.pjaxMode,
tooltip: 'Please keep it enabled unless Gitako crashes after redirecting',
overwrite: {
value: pjaxMode => pjaxMode === 'native',
onChange: checked => (checked ? 'native' : 'pjax-api'),
},
},
{
key: 'copyFileButton',
label: 'Copy file button',
@ -51,6 +66,10 @@ export function SettingsBarContent({ toggleShow }: { toggleShow: () => void }) {
const useReloadHint = useStateIO<React.ReactNode>('')
const { value: reloadHint } = useReloadHint
useUpdateEffect(() => {
window.location.reload()
}, [useConfigs().value.pjaxMode])
return (
<div className={'gitako-settings-bar'}>
<div className={'gitako-settings-bar-header'}>

View file

@ -1,12 +1,13 @@
import { Box } from '@primer/react'
import * as React from 'react'
type Props = {
title?: React.ReactNode
}
export function SettingsSection({ title, children }: React.PropsWithChildren<Props>) {
return (
<Box display="grid" gridGap={'2px'} className={'settings-section'}>
<Box display="grid" gridGap={'2px'}>
{title && <h3>{title}</h3>}
{children}
</Box>

View file

@ -2,7 +2,7 @@ import { Gitako } from 'components/Gitako'
import { platform } from 'platforms'
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { insertMountPoint } from 'utils/DOMHelper'
import { insertSideBarMountPoint, persistGitakoElements } from 'utils/DOMHelper'
import './content.scss'
if (platform.resolvePartialMetaData()) {
@ -15,7 +15,8 @@ if (platform.resolvePartialMetaData()) {
async function init() {
await injectStyles(browser.runtime.getURL('content.css'))
createRoot(insertMountPoint()).render(<Gitako />)
persistGitakoElements()
createRoot(insertSideBarMountPoint()).render(<Gitako />)
}
// injects a copy of stylesheets so that other extensions(e.g. dark reader) could read

View file

@ -47,8 +47,8 @@ export function getCommitTitle() {
export function getCurrentBranch(passive = false) {
const selectedBranchButtonSelector = [
'.repository-content #branch-select-menu summary',
'.repository-content .branch-select-menu summary',
'main #branch-select-menu summary',
'main .branch-select-menu summary',
].join()
const branchButtonElement = $(selectedBranchButtonSelector)
if (branchButtonElement) {
@ -63,8 +63,7 @@ export function getCurrentBranch(passive = false) {
if (title !== defaultTitle && !title.includes(' ')) return title
}
const findFileButtonSelector =
'#js-repo-pjax-container .repository-content .file-navigation a[data-hotkey="t"]'
const findFileButtonSelector = 'main .file-navigation a[data-hotkey="t"]'
const urlFromFindFileButton = $(
findFileButtonSelector,
element => (element as HTMLAnchorElement).href,
@ -105,8 +104,8 @@ const PAGE_TYPES = {
*/
function getCurrentPageType() {
const blobPathSelector = '#blob-path' // path next to branch switcher
const blobWrapperSelector = '.repository-content .blob-wrapper table'
const readmeSelector = '.repository-content .readme'
const blobWrapperSelector = 'main .blob-wrapper table'
const readmeSelector = 'main .readme'
const searchResultSelector = '.codesearch-results'
return (
$(searchResultSelector, () => PAGE_TYPES.SEARCH) ||
@ -119,7 +118,7 @@ function getCurrentPageType() {
const REPO_TYPE_PRIVATE = 'private' as const
const REPO_TYPE_PUBLIC = 'public' as const
export function getRepoPageType() {
const headerSelector = `#js-repo-pjax-container .pagehead.repohead h1`
const headerSelector = `main .pagehead.repohead h1`
return $(headerSelector, header => {
const repoPageTypes = [REPO_TYPE_PRIVATE, REPO_TYPE_PUBLIC]
for (const repoPageType of repoPageTypes) {
@ -136,7 +135,7 @@ export function getRepoPageType() {
*/
export function getCodeElement() {
if (getCurrentPageType() === PAGE_TYPES.RAW_TEXT) {
const codeContentSelector = '.repository-content .data table'
const codeContentSelector = 'main .data table'
const codeContentElement = $(codeContentSelector)
if (!codeContentElement) {
raiseError(new Error('cannot find code content element'))
@ -167,7 +166,7 @@ export function attachCopyFileBtn() {
}
if (!buttonGroup) {
const buttonGroupSelector = '.repository-content .Box-header .BtnGroup'
const buttonGroupSelector = 'main .Box-header .BtnGroup'
const buttonGroups = document.querySelectorAll(buttonGroupSelector)
const $buttonGroup = buttonGroups[buttonGroups.length - 1]
if ($buttonGroup) buttonGroup = $buttonGroup as HTMLElement
@ -189,9 +188,9 @@ export function attachCopyFileBtn() {
}
export function attachCopySnippet() {
const readmeSelector = '.repository-content div#readme'
const readmeSelector = 'main div#readme'
return $(readmeSelector, () => {
const readmeArticleSelector = '.repository-content div#readme article'
const readmeArticleSelector = 'main div#readme article'
return $(
readmeArticleSelector,
readmeElement => {
@ -236,10 +235,10 @@ export function attachCopySnippet() {
},
() => {
// in URL like `/{user}/{repo}/delete/{branch}/path/to/file
const deleteReadmeSelector = '.repository-content div#readme del'
const deleteReadmeSelector = 'main div#readme del'
if (!$(deleteReadmeSelector)) {
// in pages where readme is not markdown, e.g. txt
const plainReadmeSelector = '.repository-content div#readme .plain'
const plainReadmeSelector = 'main div#readme .plain'
if (!$(plainReadmeSelector)) {
raiseError(
new Error('cannot find mount point for copy snippet button while readme exists'),

View file

@ -1,6 +1,7 @@
import { useConfigs } from 'containers/ConfigsContext'
import { GITHUB_OAUTH } from 'env'
import { Base64 } from 'js-base64'
import { configRef } from 'utils/config/helper'
import { resolveGitModules } from 'utils/gitSubmodule'
import { sortFoldersToFront } from 'utils/treeParser'
import * as API from './API'
@ -86,11 +87,6 @@ export function isEnterprise() {
const pathSHAMap = new Map<string, string>()
// Try lookup PJAX containers, #js-repo-pjax-container could exist while #repo-content-pjax-container does not.
const pjaxContainerSelector = ['#repo-content-pjax-container', '#js-repo-pjax-container'].find(
selector => document.querySelector(selector),
)
export const GitHub: Platform = {
isEnterprise,
resolvePartialMetaData() {
@ -196,17 +192,25 @@ export const GitHub: Platform = {
useGitHubCodeFold(codeFolding)
useEnterpriseStatBarStyleFix()
},
delegatePJAXProps(options) {
if (!options?.node || options.node.type === 'blob')
delegatePJAXProps: options => {
if (configRef.pjaxMode === 'native' && (!options?.node || options.node.type === 'blob')) {
const pjaxContainerSelector = 'main'
const turboContainerId = 'repo-content-turbo-frame'
return {
'data-pjax': pjaxContainerSelector,
'data-turbo-frame': turboContainerId,
onClick() {
/* Overwriting default onClick */
},
}
}
},
loadWithPJAX(url, element) {
element.click()
loadWithPJAX: (url, element) => {
if (configRef.pjaxMode === 'native') {
element.click()
return true
}
},
}

View file

@ -26,6 +26,6 @@ type Platform = {
delegatePJAXProps?(options?: { node?: TreeNode }):
| (React.DOMAttributes<HTMLElement> & Record<string, unknown>) // support data-* attributes
| void
loadWithPJAX?(url: string, element: HTMLElement): void
loadWithPJAX?(url: string, element: HTMLElement): boolean | void
usePlatformHooks?(): void
}

View file

@ -43,7 +43,7 @@ export function $<T1, T2>(
selector: string,
existCallback: (element: HTMLElement) => T1,
otherwise: () => T2,
): T1 | T2 | null
): T1 | T2
export function $<T2>(
selector: string,
existCallback: undefined | null,
@ -59,29 +59,49 @@ export function $(selector: string, existCallback?: any, otherwise?: any) {
}
/**
* add the root element into DOM
* DOM Structure after calling the `insert*MountPoint` functions
*
* <html>
* <body>
* <div id={rootElementID}>
* <div id={sidebarMountPointID}>
* </div>
* <div id={logoMountPointID}>
* </div>
* </div>
* </body>
* </html>
*/
export function insertMountPoint() {
const mountPointContainer = document.body // TODO: when replace this, refactor root of `$`
return $(formatID(rootElementID), undefined, () => {
const rootElement = document.createElement('div')
rootElement.setAttribute('id', rootElementID)
document.body.appendChild(rootElement)
return rootElement
const element = document.createElement('div')
element.setAttribute('id', rootElementID)
mountPointContainer.appendChild(element)
return element
})
}
export function insertSideBarMountPoint() {
const mountPointElement = insertMountPoint()
const sidebarMountPointID = 'gitako-sidebar-mount-point'
return $(formatID(sidebarMountPointID), undefined, () => {
const sideBarElement = document.createElement('div')
sideBarElement.setAttribute('id', sidebarMountPointID)
mountPointElement.appendChild(sideBarElement)
return sideBarElement
})
}
/**
* add the logo element into DOM
*/
export function insertLogoMountPoint() {
return $(formatID(rootElementID), container => {
const logoID = 'gitako-logo-mount-point'
return $(formatID(logoID), undefined, function createLogoMountPoint() {
const logoMountElement = document.createElement('div')
logoMountElement.setAttribute('id', logoID)
container.appendChild(logoMountElement)
return logoMountElement
})
const mountPointElement = insertMountPoint()
const logoMountPointID = 'gitako-logo-mount-point'
return $(formatID(logoMountPointID), undefined, () => {
const logoMountElement = document.createElement('div')
logoMountElement.setAttribute('id', logoMountPointID)
mountPointElement.appendChild(logoMountElement)
return logoMountElement
})
}
@ -171,3 +191,46 @@ export function formatClass(className: string) {
export function parseIntFromElement(e: HTMLElement): number {
return parseInt((e.innerText || '').replace(/[^0-9]/g, ''))
}
/**
* Unlike the good-old-PJAX-time, now GitHub replaces whole body element after redirecting using turbo.
* If move Gitako mount point from `body` to `html`, Gitako style would break because it inherits style from GitHub body.
* The temporary solution is recovery Gitako elements once the body is removed.
*/
export function persistGitakoElements(mountPointElement = insertMountPoint()) {
mountPointElement.setAttribute('data-turbo-permanent', '')
const observer = new MutationObserver(mutations => {
for (const { addedNodes, removedNodes } of mutations) {
const [addedBody, removedBody] = [addedNodes, removedNodes].map(findBodyElement)
if (addedBody && removedBody) {
// hard-coded list due to limited time
// TODO: refactor in a better practice
// migrate gitako attributes, e.g. class
const propertiesNeedToMigrate = ['--gitako-width']
for (const property of propertiesNeedToMigrate) {
const oldValue = removedBody.style.getPropertyValue(property)
if (oldValue) addedBody.style.setProperty(property, oldValue)
}
const cssClassesNeedToMigrate = [bodySpacingClassName]
for (const cssClass of cssClassesNeedToMigrate) {
if (removedBody.classList.contains(cssClass)) addedBody.classList.add(cssClass)
}
// move gitako elements
if (!addedBody.contains(mountPointElement)) addedBody.appendChild(mountPointElement)
if (removedBody.contains(mountPointElement)) removedBody.removeChild(mountPointElement)
}
}
function findBodyElement(addedNodes: NodeList) {
return Array.from(addedNodes).find(addedNode => addedNode instanceof HTMLBodyElement) as
| HTMLBodyElement
| undefined
}
})
observer.observe(document.documentElement, {
childList: true,
})
}

View file

@ -1,5 +1,6 @@
import { SearchMode } from 'components/searchModes'
import { storageHelper } from 'utils/storageHelper'
import { platformName } from 'platforms'
import { Storage, storageHelper } from 'utils/storageHelper'
import { migrateConfig } from './migrations'
export type Config = {
@ -19,6 +20,7 @@ export type Config = {
codeFolding: boolean
compactFileTree: boolean
restoreExpandedFolders: boolean
pjaxMode: 'native' | 'pjax-api'
}
export type ConfigKeys = keyof Config
@ -40,18 +42,20 @@ enum configKeys {
codeFolding = 'codeFolding',
compactFileTree = 'compactFileTree',
restoreExpandedFolders = 'restoreExpandedFolders',
pjaxMode = 'pjaxMode',
}
// do NOT use platform name
// NOT use platform name to distinguish GHE from github.com
const platformStorageKey = `platform_` + window.location.host.toLowerCase()
const isInGitHub = platformStorageKey === 'platform_github.com'
export const defaultConfigs: Config = {
export const getDefaultConfigs: () => Config = () => ({
sideBarWidth: 260,
shortcut: undefined,
accessToken: '',
compressSingletonFolder: true,
copyFileButton: platformStorageKey !== 'platform_github.com', // false when on github.com,
copySnippetButton: platformStorageKey !== 'platform_github.com', // false when on github.com
copyFileButton: !isInGitHub, // disable on github.com
copySnippetButton: !isInGitHub, // disable on github.com
intelligentToggle: null,
icons: 'rich',
toggleButtonVerticalDistance: 124, // align with GitHub's navbar items
@ -62,28 +66,37 @@ export const defaultConfigs: Config = {
codeFolding: true,
compactFileTree: false,
restoreExpandedFolders: true,
}
pjaxMode: platformName === 'GitHub' ? 'native' : 'pjax-api', // use native on GitHub
})
const configKeyArray = Object.values(configKeys)
function applyDefaultConfigs(configs: Partial<Config>) {
const defaultConfigs = getDefaultConfigs()
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 }
export type VersionedConfig<SiteConfig> = Record<string, SiteConfig> & Storage
const prepareConfig = new Promise((resolve, reject) => migrateConfig().then(resolve, reject))
export const configRef: Partial<Config> = {}
const updateConfigRef = async (config: Partial<Config>) => {
Object.assign(configRef, config)
}
const configMigration = migrateConfig()
configMigration.then(async () => updateConfigRef(await get()))
async function get(): Promise<Config> {
await prepareConfig
await configMigration
const config = await storageHelper.get<Record<string, Config>>([platformStorageKey])
return applyDefaultConfigs(config?.[platformStorageKey] || {})
}
async function set(config: Config) {
updateConfigRef(config)
return await storageHelper.set({ [platformStorageKey]: config })
}

View file

@ -1,13 +1,12 @@
import { is } from 'utils/is'
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: JSONObject | void = await storageHelper.get<VersionedConfig<Config> & Storage>([
const config: JSONObject | void = await storageHelper.get<VersionedConfig<Config>>([
'configVersion',
'platform_undefined',
'platform_GitHub',

View file

@ -0,0 +1,13 @@
import { storageHelper } from 'utils/storageHelper'
import { Migration, onConfigOutdated } from '.'
import { version } from '../../../../package.json'
// Run every time a new version is released.
export const migration: Migration = {
version,
async migrate(version) {
await onConfigOutdated(version, async () => {
await storageHelper.set({ raiseErrorCache: [] })
})
},
}

View file

@ -1,10 +1,12 @@
import { storageHelper } from 'utils/storageHelper'
import { storageHelper, storageKeys } from 'utils/storageHelper'
import { version } from '../../../../package.json'
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'
import { migration as v3v5v0 } from './3.5.0'
import { migration as clearRaiseErrorCache } from './clearRaiseErrorCache'
export type Migration = {
version: string
@ -13,21 +15,29 @@ export type Migration = {
export async function migrateConfig() {
const migrations: Migration[] = [v1v0v1, v1v3v4, v2v6v0, v3v0v0, v3v5v0]
migrations.push(clearRaiseErrorCache) // Make sure this is run after other version-specific migrations
for (const { version, migrate } of migrations) {
await migrate(version)
}
await storageHelper.set({ [storageKeys.configVersion]: version })
}
export async function onConfigOutdated<T extends JSONObject>(
configVersion: string,
migrationConfigVersion: string,
runIfOutdated: (config: T) => Async<void>,
) {
const config = await storageHelper.get<Storage>()
if (config && config.configVersion < configVersion) {
const { configVersion: $configVersion, ...restConfig } = config // eslint-disable-line @typescript-eslint/no-unused-vars
await runIfOutdated(restConfig as T)
await storageHelper.set({ configVersion })
if (config) {
const {
[storageKeys.configVersion]: savedConfigVersion,
[storageKeys.raiseErrorCache]: __, // eslint-disable-line @typescript-eslint/no-unused-vars
...restConfig
} = config
if (savedConfigVersion < migrationConfigVersion) {
await runIfOutdated(restConfig as T)
}
}
}

View file

@ -1,4 +1,4 @@
import { resolveDiffGraphMeta } from './general'
import { atomicAsyncFunction, resolveDiffGraphMeta } from './general'
it(`should resolve diff stat graph meta properly`, () => {
const example = `
@ -27,3 +27,37 @@ it(`should resolve diff stat graph meta properly`, () => {
expect([meta.g, meta.r]).toEqual([g, r])
})
})
it(`should schedule atomic promises properly`, async () => {
const sleep = (duration: number) => new Promise(resolve => setTimeout(resolve, duration))
const recorder: string[] = []
const sleepWithNoise = async (duration: number, noise: string) => {
await sleep(duration)
recorder.push(noise)
return noise
}
const atomicSleep = atomicAsyncFunction(sleepWithNoise)
recorder.length = 0
const atomicReturns = await Promise.all([atomicSleep(200, 'a'), atomicSleep(100, 'b')])
// Expected time sheet
// 0 100 200 300
// [a ]
// [b ]
// Recorder: [a, b]
//
expect(recorder).toEqual(['a', 'b'])
expect(atomicReturns).toEqual(['a', 'b'])
recorder.length = 0
const normalReturns = await Promise.all([sleepWithNoise(200, 'a'), sleepWithNoise(100, 'b')])
// Time sheet if not atomic
// 0 100 200
// [a ]
// [b ]
// Recorder: [b, a]
expect(recorder).toEqual(['b', 'a'])
expect(normalReturns).toEqual(['a', 'b'])
})

View file

@ -207,10 +207,6 @@ export function formatHash(hash?: string) {
return ''
}
export function isNotFalsy<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null
}
export function forOf<T, R>(target: T, callback: <K extends keyof T>(key: K, value: T[K]) => R) {
for (const key of Object.keys(target)) {
const $key = key as keyof typeof target
@ -221,3 +217,11 @@ export function forOf<T, R>(target: T, callback: <K extends keyof T>(key: K, val
// eslint-disable-next-line @typescript-eslint/no-empty-function
export function noop() {}
export function atomicAsyncFunction<Args extends any[], R>(fn: (...args: Args) => Promise<R>) {
let last: Promise<R> | undefined
return async (...args: Args) => {
last = last ? last.then(() => fn(...args)) : fn(...args)
return last
}
}

View file

@ -1,8 +1,11 @@
import { Config, Pjax } from 'pjax-api'
import { useConfigs } from 'containers/ConfigsContext'
import { Config } from 'pjax-api'
import { platform } from 'platforms'
import * as React from 'react'
import { useEvent } from 'react-use'
// TODO: rename PJAX
const config: Config = {
areas: [
// github
@ -28,15 +31,20 @@ const config: Config = {
}
export function usePJAX() {
const { pjaxMode } = useConfigs().value
// make history travel work
React.useEffect(() => {
new Pjax({
...config,
filter() {
return false
},
})
}, [])
if (pjaxMode === 'pjax-api') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Pjax } = require('pjax-api')
new Pjax({
...config,
filter() {
return false
},
})
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// bindings for legacy support
useRedirectedEvents(window, 'pjax:fetch', 'pjax:start', document)
@ -44,12 +52,15 @@ export function usePJAX() {
}
export const loadWithPJAX = (url: string, element: HTMLElement) => {
if (platform.loadWithPJAX) platform.loadWithPJAX(url, element)
else Pjax.assign(url, config)
// eslint-disable-next-line @typescript-eslint/no-var-requires
platform.loadWithPJAX?.(url, element) || require('pjax-api').Pjax.assign(url, config)
}
export function useOnPJAXDone(callback: () => void) {
useEvent('pjax:end', callback, document)
useEvent('pjax:end', callback, document) // legacy support
// 'turbo:render' should be the best timing but GitHub has attached a mutation observer on body to block that
// TODO: fire at turbo:render
useEvent('turbo:load', callback, document)
}
export function useRedirectedEvents(

View file

@ -82,6 +82,7 @@ export const is = {
false: <T>(d: T | false): d is T => d !== false,
number: <T>(d: T | number): d is T => typeof d !== 'number',
string: <T>(d: T | string): d is T => typeof d !== 'string',
nil: <T>(d: T | null | undefined): d is T => d !== null && d !== undefined,
},
JSON: {
object: (d: unknown): d is JSONObject =>

View file

@ -1,8 +1,15 @@
const localStorage = browser.storage.local
const keys = {
configVersion: 'configVersion',
raiseErrorCache: 'raiseErrorCache',
} as const
export const storageKeys = keys
export type Storage = {
// save root level `configVersion` for easier future migrating
[key in EnumString<'configVersion'>]: string
// save root level keys for easier future migrating
[key in EnumString<keyof typeof keys>]: string
// separate different platform configs to simplify interactions with browser storage API
// e.g.