mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
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:
commit
d6e9fcc163
24 changed files with 328 additions and 104 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
{node.comments.active > 9 ? '9+' : node.comments.active}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
2
src/platforms/platform.d.ts
vendored
2
src/platforms/platform.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
13
src/utils/config/migrations/clearRaiseErrorCache.ts
Normal file
13
src/utils/config/migrations/clearRaiseErrorCache.ts
Normal 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: [] })
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue