diff --git a/__tests__/cases/non-parallel/pjax.commits-page.ts b/__tests__/cases/non-parallel/pjax.commits-page.ts index cdd87bc..a719005 100644 --- a/__tests__/cases/non-parallel/pjax.commits-page.ts +++ b/__tests__/cases/non-parallel/pjax.commits-page.ts @@ -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() diff --git a/__tests__/cases/non-parallel/project-page.gitako.ts b/__tests__/cases/non-parallel/project-page.gitako.ts index 6a831a9..8182fda 100644 --- a/__tests__/cases/non-parallel/project-page.gitako.ts +++ b/__tests__/cases/non-parallel/project-page.gitako.ts @@ -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')) diff --git a/__tests__/utils.ts b/__tests__/utils.ts index b4b3d61..88d5238 100644 --- a/__tests__/utils.ts +++ b/__tests__/utils.ts @@ -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 }) } } diff --git a/package.json b/package.json index a1d0f29..dcfd7ae 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/analytics.ts b/src/analytics.ts index 1ad233f..c3dbfe9 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -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([ @@ -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) }) diff --git a/src/components/FileExplorer/hooks/useNodeRenderers.tsx b/src/components/FileExplorer/hooks/useNodeRenderers.tsx index be6aafb..53db57c 100644 --- a/src/components/FileExplorer/hooks/useNodeRenderers.tsx +++ b/src/components/FileExplorer/hooks/useNodeRenderers.tsx @@ -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) => {render(node)}) @@ -43,7 +43,9 @@ export function useRenderFileCommentAmounts() { node.comments.active } active, ${node.comments.resolved} resolved`} > - {node.comments.active > 9 ? '9+' : node.comments.active} + +   + {node.comments.active > 9 ? '9+' : node.comments.active} ) : null } diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index aca0575..24f79d9 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -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 = diff --git a/src/components/SideBarBodyWrapper.tsx b/src/components/SideBarBodyWrapper.tsx index 96e0104..a268f66 100644 --- a/src/components/SideBarBodyWrapper.tsx +++ b/src/components/SideBarBodyWrapper.tsx @@ -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 (
{ - setSize(defaultConfigs.sideBarWidth) - applySizeToCSSVariables(sizeVariableMountPoint, defaultConfigs.sideBarWidth) + setSize(defaultSideBarWidth) + applySizeToCSSVariables(sizeVariableMountPoint, defaultSideBarWidth) }} onResizeStateChange={onResizeStateChange} size={dummySize} diff --git a/src/components/settings/SettingsBar.tsx b/src/components/settings/SettingsBar.tsx index dd0cccd..50f3356 100644 --- a/src/components/settings/SettingsBar.tsx +++ b/src/components/settings/SettingsBar.tsx @@ -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('') const { value: reloadHint } = useReloadHint + useUpdateEffect(() => { + window.location.reload() + }, [useConfigs().value.pjaxMode]) + return (
diff --git a/src/components/settings/SettingsSection.tsx b/src/components/settings/SettingsSection.tsx index caf5a29..e17bcc0 100644 --- a/src/components/settings/SettingsSection.tsx +++ b/src/components/settings/SettingsSection.tsx @@ -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) { return ( - + {title &&

{title}

} {children}
diff --git a/src/content.tsx b/src/content.tsx index 7cee36a..d0f37e1 100644 --- a/src/content.tsx +++ b/src/content.tsx @@ -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() + persistGitakoElements() + createRoot(insertSideBarMountPoint()).render() } // injects a copy of stylesheets so that other extensions(e.g. dark reader) could read diff --git a/src/platforms/GitHub/DOMHelper.ts b/src/platforms/GitHub/DOMHelper.ts index 714cf65..c12d209 100644 --- a/src/platforms/GitHub/DOMHelper.ts +++ b/src/platforms/GitHub/DOMHelper.ts @@ -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'), diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index 99a05a5..a99e5c0 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -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() -// 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 + } }, } diff --git a/src/platforms/platform.d.ts b/src/platforms/platform.d.ts index f449f53..75a2c1d 100644 --- a/src/platforms/platform.d.ts +++ b/src/platforms/platform.d.ts @@ -26,6 +26,6 @@ type Platform = { delegatePJAXProps?(options?: { node?: TreeNode }): | (React.DOMAttributes & Record) // support data-* attributes | void - loadWithPJAX?(url: string, element: HTMLElement): void + loadWithPJAX?(url: string, element: HTMLElement): boolean | void usePlatformHooks?(): void } diff --git a/src/utils/DOMHelper.ts b/src/utils/DOMHelper.ts index 2884e70..8bc6244 100644 --- a/src/utils/DOMHelper.ts +++ b/src/utils/DOMHelper.ts @@ -43,7 +43,7 @@ export function $( selector: string, existCallback: (element: HTMLElement) => T1, otherwise: () => T2, -): T1 | T2 | null +): T1 | T2 export function $( 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 + * + * + * + *
+ *
+ *
+ *
+ *
+ *
+ * + * */ + 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, + }) +} diff --git a/src/utils/config/helper.ts b/src/utils/config/helper.ts index bd86a7d..d90fac8 100644 --- a/src/utils/config/helper.ts +++ b/src/utils/config/helper.ts @@ -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) { + 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 = Record & { configVersion: string } +export type VersionedConfig = Record & Storage -const prepareConfig = new Promise((resolve, reject) => migrateConfig().then(resolve, reject)) +export const configRef: Partial = {} +const updateConfigRef = async (config: Partial) => { + Object.assign(configRef, config) +} + +const configMigration = migrateConfig() +configMigration.then(async () => updateConfigRef(await get())) async function get(): Promise { - await prepareConfig + await configMigration const config = await storageHelper.get>([platformStorageKey]) return applyDefaultConfigs(config?.[platformStorageKey] || {}) } async function set(config: Config) { + updateConfigRef(config) return await storageHelper.set({ [platformStorageKey]: config }) } diff --git a/src/utils/config/migrations/1.3.4.ts b/src/utils/config/migrations/1.3.4.ts index bf02f4f..4558fa5 100644 --- a/src/utils/config/migrations/1.3.4.ts +++ b/src/utils/config/migrations/1.3.4.ts @@ -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 & Storage>([ + const config: JSONObject | void = await storageHelper.get>([ 'configVersion', 'platform_undefined', 'platform_GitHub', diff --git a/src/utils/config/migrations/clearRaiseErrorCache.ts b/src/utils/config/migrations/clearRaiseErrorCache.ts new file mode 100644 index 0000000..a467457 --- /dev/null +++ b/src/utils/config/migrations/clearRaiseErrorCache.ts @@ -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: [] }) + }) + }, +} diff --git a/src/utils/config/migrations/index.ts b/src/utils/config/migrations/index.ts index a864f86..58936d1 100644 --- a/src/utils/config/migrations/index.ts +++ b/src/utils/config/migrations/index.ts @@ -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( - configVersion: string, + migrationConfigVersion: string, runIfOutdated: (config: T) => Async, ) { const config = await storageHelper.get() - 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) + } } } diff --git a/src/utils/general.test.ts b/src/utils/general.test.ts index 205acc8..acb3949 100644 --- a/src/utils/general.test.ts +++ b/src/utils/general.test.ts @@ -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']) +}) diff --git a/src/utils/general.ts b/src/utils/general.ts index 0b6925f..53f3189 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -207,10 +207,6 @@ export function formatHash(hash?: string) { return '' } -export function isNotFalsy(value: T | undefined | null): value is T { - return value !== undefined && value !== null -} - export function forOf(target: T, callback: (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(target: T, callback: (key: K, val // eslint-disable-next-line @typescript-eslint/no-empty-function export function noop() {} + +export function atomicAsyncFunction(fn: (...args: Args) => Promise) { + let last: Promise | undefined + return async (...args: Args) => { + last = last ? last.then(() => fn(...args)) : fn(...args) + return last + } +} diff --git a/src/utils/hooks/usePJAX.ts b/src/utils/hooks/usePJAX.ts index 9234e56..8dbda16 100644 --- a/src/utils/hooks/usePJAX.ts +++ b/src/utils/hooks/usePJAX.ts @@ -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( diff --git a/src/utils/is.ts b/src/utils/is.ts index 41b95d0..88a590a 100644 --- a/src/utils/is.ts +++ b/src/utils/is.ts @@ -82,6 +82,7 @@ export const is = { false: (d: T | false): d is T => d !== false, number: (d: T | number): d is T => typeof d !== 'number', string: (d: T | string): d is T => typeof d !== 'string', + nil: (d: T | null | undefined): d is T => d !== null && d !== undefined, }, JSON: { object: (d: unknown): d is JSONObject => diff --git a/src/utils/storageHelper.ts b/src/utils/storageHelper.ts index e393f9f..f677c0c 100644 --- a/src/utils/storageHelper.ts +++ b/src/utils/storageHelper.ts @@ -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]: string // separate different platform configs to simplify interactions with browser storage API // e.g.