mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
Merge branch 'next' into develop
This commit is contained in:
commit
feb2fb491a
165 changed files with 6019 additions and 8321 deletions
|
|
@ -1,5 +1,5 @@
|
|||
# The client ID you received from GitHub for your GitHub App.
|
||||
GITHUB_OAUTH_CLIENT_ID=GITHUB_OAUTH_CLIENT_ID
|
||||
|
||||
# The client secret you received from GitHub for your GitHub App.
|
||||
GITHUB_OAUTH_CLIENT_SECRET=GITHUB_OAUTH_CLIENT_SECRET
|
||||
|
||||
SENTRY_PUBLIC_KEY=SENTRY_PUBLIC_KEY
|
||||
SENTRY_PROJECT_ID=SENTRY_PROJECT_ID
|
||||
|
|
|
|||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- name: Cache deps
|
||||
- name: Cache Dependencies
|
||||
uses: actions/cache@v1
|
||||
id: yarn-cache
|
||||
with:
|
||||
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install deps
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
|
||||
run: |
|
||||
|
|
@ -48,7 +48,11 @@ jobs:
|
|||
run: |
|
||||
make build
|
||||
|
||||
- name: Test
|
||||
- name: Unit Test
|
||||
run: |
|
||||
yarn jest src
|
||||
|
||||
- name: E2E Test
|
||||
uses: mujo-code/puppeteer-headful@master
|
||||
env:
|
||||
CI: 'true'
|
||||
|
|
|
|||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged --quiet
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
*-profile/
|
||||
dist/
|
||||
vscode-icons/
|
||||
Safari
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expectToFind, expectToNotFind, sleep, waitForLegacyPJAXRedirect } from '../../utils'
|
||||
import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../../utils'
|
||||
|
||||
describe(`in Gitako project page`, () => {
|
||||
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/commits/develop'))
|
||||
|
|
@ -10,7 +10,7 @@ describe(`in Gitako project page`, () => {
|
|||
)
|
||||
if (commitLinks.length < 2) throw new Error(`No enough commits`)
|
||||
commitLinks[i].click()
|
||||
await waitForLegacyPJAXRedirect()
|
||||
await waitForRedirect()
|
||||
await expectToFind('div.commit')
|
||||
await sleep(1000)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { expectToFind, expectToNotFind, sleep, waitForLegacyPJAXRedirect } from '../../utils'
|
||||
import {
|
||||
expectToFind,
|
||||
expectToNotFind,
|
||||
sleep, waitForRedirect
|
||||
} from '../../utils'
|
||||
|
||||
describe(`in Gitako project page`, () => {
|
||||
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/tree/develop/src'))
|
||||
|
|
@ -9,7 +13,7 @@ describe(`in Gitako project page`, () => {
|
|||
`.js-details-container div[role="row"] div[role="rowheader"] a[title*="."]`,
|
||||
)
|
||||
if (commitLinks.length < 2) throw new Error(`No enough files`)
|
||||
await waitForLegacyPJAXRedirect(async () => {
|
||||
await waitForRedirect(async () => {
|
||||
await commitLinks[i].click()
|
||||
})
|
||||
await expectToFind('table.js-file-line-container')
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import {
|
|||
expandFloatModeSidebar,
|
||||
patientClick,
|
||||
selectFileTreeItem,
|
||||
sleep,
|
||||
waitForLegacyPJAXRedirect
|
||||
sleep, waitForRedirect
|
||||
} from '../../utils'
|
||||
|
||||
describe(`in Gitako project page`, () => {
|
||||
|
|
@ -15,14 +14,14 @@ describe(`in Gitako project page`, () => {
|
|||
|
||||
await expandFloatModeSidebar()
|
||||
await patientClick(selectFileTreeItem('src/analytics.ts'))
|
||||
await waitForLegacyPJAXRedirect()
|
||||
await waitForRedirect()
|
||||
await collapseFloatModeSidebar()
|
||||
|
||||
await page.click('a[data-selected-links^="repo_issues "]')
|
||||
await waitForLegacyPJAXRedirect()
|
||||
await waitForRedirect()
|
||||
|
||||
await page.click('a[data-selected-links^="repo_pulls "]')
|
||||
await waitForLegacyPJAXRedirect()
|
||||
await waitForRedirect()
|
||||
|
||||
page.goBack()
|
||||
await sleep(1000)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import {
|
|||
expectToNotFind,
|
||||
patientClick,
|
||||
selectFileTreeItem,
|
||||
sleep,
|
||||
waitForLegacyPJAXRedirect
|
||||
sleep, waitForRedirect
|
||||
} from '../../utils'
|
||||
|
||||
describe(`in Gitako project page`, () => {
|
||||
|
|
@ -16,12 +15,12 @@ describe(`in Gitako project page`, () => {
|
|||
|
||||
await expandFloatModeSidebar()
|
||||
await patientClick(selectFileTreeItem('.babelrc'))
|
||||
await waitForLegacyPJAXRedirect()
|
||||
await waitForRedirect()
|
||||
|
||||
// The selector for file content
|
||||
await expectToFind('table.js-file-line-container')
|
||||
|
||||
await waitForLegacyPJAXRedirect(async () => {
|
||||
await waitForRedirect(async () => {
|
||||
await sleep(1000) // This prevents failing in some cases due to some mystery scheduling issue of puppeteer or jest
|
||||
page.goBack()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import {
|
|||
} from '../../utils'
|
||||
|
||||
describe(`in Gitako project page`, () => {
|
||||
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/tree/test/200-changed-files-200-lines-each'))
|
||||
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')
|
||||
|
|
@ -26,7 +28,7 @@ describe(`in Gitako project page`, () => {
|
|||
const box = await filesEle?.boundingBox()
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + 40, box.y + 40)
|
||||
await scroll({ totalDistance: 7000, stepDistance: 100 })
|
||||
await scroll({ totalDistance: 10000, stepDistance: 100 })
|
||||
|
||||
// node of tsconfig.json should be rendered now
|
||||
await expectToFind(selectFileTreeItem('tsconfig.json'))
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import {
|
||||
expectToFind,
|
||||
selectFileTreeItem,
|
||||
sleep,
|
||||
waitForLegacyPJAXRedirect
|
||||
} from '../../utils'
|
||||
import { expectToFind, selectFileTreeItem, sleep, waitForRedirect } from '../../utils'
|
||||
|
||||
describe(`in Gitako project page`, () => {
|
||||
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/tree/develop/src'))
|
||||
|
||||
it('expand to target on load and after PJAX', async () => {
|
||||
it('expand to target on load and after redirect', async () => {
|
||||
await sleep(3000)
|
||||
|
||||
// Expect Gitako sidebar to have expanded src to see contents
|
||||
|
|
@ -17,7 +12,7 @@ describe(`in Gitako project page`, () => {
|
|||
await page.click(
|
||||
`.js-details-container div[role="row"] div[role="rowheader"] [title="components"]`,
|
||||
)
|
||||
await waitForLegacyPJAXRedirect()
|
||||
await waitForRedirect()
|
||||
|
||||
// Expect Gitako sidebar to have expanded components and see contents
|
||||
await expectToFind(selectFileTreeItem('src/components/Gitako.tsx'))
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export async function listenTo<Args extends any[] = any[]>(
|
|||
(event, target, callbackName, oneTime) => {
|
||||
const t = target === 'document' ? document : window
|
||||
const onEvent = (...args: any[]): void => {
|
||||
;((window[callbackName as any] as any) as (...args: any[]) => void)(...args)
|
||||
;(window[callbackName as any] as any as (...args: any[]) => void)(...args)
|
||||
if (oneTime) t.removeEventListener(event, onEvent)
|
||||
}
|
||||
t.addEventListener(event, onEvent)
|
||||
|
|
@ -71,6 +71,24 @@ export async function waitForLegacyPJAXRedirect(action?: () => void | Promise<vo
|
|||
return promise
|
||||
}
|
||||
|
||||
export async function waitForTurboRedirect(action?: () => void | Promise<void>) {
|
||||
const promise = once('turbo:load', 'document')
|
||||
await action?.()
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function waitForRedirect(action?: () => void | Promise<void>) {
|
||||
let fired = false
|
||||
const $action =
|
||||
action &&
|
||||
(() => {
|
||||
if (fired) return
|
||||
fired = true
|
||||
return action()
|
||||
})
|
||||
return Promise.race([waitForLegacyPJAXRedirect($action), waitForTurboRedirect($action)])
|
||||
}
|
||||
|
||||
export function selectFileTreeItem(path: string): string {
|
||||
return `.gitako-side-bar .files a[title="${path}"]`
|
||||
}
|
||||
|
|
@ -81,7 +99,9 @@ export async function patientClick(selector: string) {
|
|||
}
|
||||
|
||||
export async function expandFloatModeSidebar() {
|
||||
const rect = await (await page.$('.gitako-toggle-show-button'))?.evaluate(button => {
|
||||
const rect = await (
|
||||
await page.$('.gitako-toggle-show-button')
|
||||
)?.evaluate(button => {
|
||||
const { x, y, width, height } = button.getBoundingClientRect()
|
||||
// pass required properties to avoid serialization issues
|
||||
return { x, y, width, height }
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ module.exports = {
|
|||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
// testEnvironment: 'node',
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
|
|
|||
107
package.json
107
package.json
|
|
@ -8,41 +8,41 @@
|
|||
"private": true,
|
||||
"homepage": "https://github.com/EnixCoda/Gitako",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"dev": "VERSION=dev-v$(node scripts/get-version.js) webpack --watch",
|
||||
"dev-safari": "TARGET=safari yarn run dev",
|
||||
"debug-firefox": "web-ext run --source-dir=dist --firefox-profile=firefox-profile --profile-create-if-missing --keep-profile-changes --start-url github.com/EnixCoda/Gitako",
|
||||
"debug-firefox": "web-ext run --source-dir=dist --firefox-profile=firefox-profile --profile-create-if-missing --keep-profile-changes --start-url https://github.com/EnixCoda/Gitako",
|
||||
"analyse-bundle": "ANALYSE= NODE_ENV=production webpack",
|
||||
"postinstall": "rm -rf node_modules/@types/react-native && node scripts/fix-pjax-api",
|
||||
"postinstall": "node scripts/fix-deps",
|
||||
"build": "VERSION=v$(node scripts/get-version.js) NODE_ENV=production webpack",
|
||||
"roll": "make release",
|
||||
"test": "yarn run test:parallel && yarn run test:non-parallel",
|
||||
"test:parallel": "NODE_ENV=test jest --config __tests__/jest.parallel.config.js",
|
||||
"test:non-parallel": "NODE_ENV=test jest --config __tests__/jest.non-parallel.config.js",
|
||||
"format": "prettier --write '**/*.{js,jsx,ts,tsx}'"
|
||||
"test:non-parallel": "NODE_ENV=test jest --config __tests__/jest.non-parallel.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primer/components": "^22.0.2",
|
||||
"@primer/css": "^15.2.0",
|
||||
"@primer/octicons-react": "^10.0.0",
|
||||
"@primer/css": "^20.2.2",
|
||||
"@primer/octicons-react": "^17.1.0",
|
||||
"@primer/react": "^35.2.2",
|
||||
"@sentry/browser": "^6.3.6",
|
||||
"@types/history": "^4.7.5",
|
||||
"@types/ini": "^1.3.30",
|
||||
"@types/js-base64": "^2.3.1",
|
||||
"@types/history": "^5.0.0",
|
||||
"@types/ini": "^1.3.31",
|
||||
"@types/js-base64": "^3.3.1",
|
||||
"@types/nprogress": "^0.0.29",
|
||||
"@types/react": "^16.8.24",
|
||||
"@types/react-dom": "^16.8.5",
|
||||
"@types/react-window": "^1.8.1",
|
||||
"@types/styled-components": "^5.1.3",
|
||||
"@types/styled-system__css": "^5.0.14",
|
||||
"ini": "^1.3.6",
|
||||
"js-base64": "^2.5.1",
|
||||
"@types/react": "^18.0.9",
|
||||
"@types/react-dom": "^18.0.3",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"ini": "^3.0.0",
|
||||
"js-base64": "^3.7.2",
|
||||
"nprogress": "^0.2.0",
|
||||
"pjax-api": "^3.33.0",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-use": "^15.3.0",
|
||||
"react-window": "^1.8.5",
|
||||
"styled-components": "^5.2.0",
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-iifc": "^1.2.0",
|
||||
"react-use": "^17.3.2",
|
||||
"react-window": "^1.8.7",
|
||||
"styled-components": "^5.3.5",
|
||||
"webext-domain-permission-toggle": "^1.0.0",
|
||||
"webext-dynamic-content-scripts": "^6.0.3",
|
||||
"webextension-polyfill": "^0.5.0"
|
||||
|
|
@ -56,31 +56,40 @@
|
|||
"@babel/preset-react": "^7.16.7",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@sentry/cli": "^1.64.2",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@types/firefox-webext-browser": "^70.0.1",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/node": "^11.10.4",
|
||||
"@types/puppeteer": "^5.4.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||
"@typescript-eslint/parser": "^5.23.0",
|
||||
"babel-loader": "^8.2.5",
|
||||
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
|
||||
"copy-webpack-plugin": "^5.0.0",
|
||||
"css-loader": "^2.1.0",
|
||||
"dotenv": "^6.2.0",
|
||||
"dotenv-webpack": "^1.7.0",
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.5.0",
|
||||
"file-loader": "^3.0.1",
|
||||
"fork-ts-checker-webpack-plugin": "^6.5.0",
|
||||
"husky": "^8.0.1",
|
||||
"jest": "^27.0.6",
|
||||
"jest-puppeteer": "^6.1.0",
|
||||
"json-loader": "^0.5.7",
|
||||
"lint-staged": "^13.0.3",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"prettier": "^2.7.1",
|
||||
"puppeteer": "^10.1.0",
|
||||
"raw-loader": "^4.0.0",
|
||||
"sass": "^1.26.2",
|
||||
"sass-loader": "^8.0.2",
|
||||
"typescript": "^4.6.3",
|
||||
"typescript": "^4.7.2",
|
||||
"uglifyjs-webpack-plugin": "^2.1.2",
|
||||
"url-loader": "^1.1.2",
|
||||
"web-ext": "^6.8.0",
|
||||
"web-ext": "^7.1.1",
|
||||
"webpack": "^4.29.6",
|
||||
"webpack-bundle-analyzer": "^3.6.0",
|
||||
"webpack-cli": "^3.1.2"
|
||||
|
|
@ -92,8 +101,54 @@
|
|||
"trailingComma": "all",
|
||||
"arrowParens": "avoid"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"webpack.config.js",
|
||||
"*jest*",
|
||||
"scripts",
|
||||
"dist",
|
||||
"*-profile",
|
||||
"Safari",
|
||||
"vscode-icons",
|
||||
"__tests__"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"react-hooks/rules-of-hooks": "off"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.scss": [
|
||||
"yarn prettier --list-different --write"
|
||||
],
|
||||
"*.{js,ts,tsx}": [
|
||||
"yarn prettier --list-different --write",
|
||||
"yarn eslint --max-warnings=0 --fix"
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
"react": "^17",
|
||||
"@types/styled-components": "^5.0.0"
|
||||
"@types/react": "^18.0.9",
|
||||
"@types/react-dom": "^18.0.3",
|
||||
"@primer/primitives": "^7.8.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
scripts/fix-deps/index.js
Normal file
40
scripts/fix-deps/index.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* This script rewrites local dependency files to resolve compatibility issues.
|
||||
* This is a bit dirty but really effective.
|
||||
*/
|
||||
|
||||
function modify(source = '', pairs = []) {
|
||||
for (const [original, replace] of pairs) {
|
||||
if (source.includes(original)) {
|
||||
source = source.replace(original, replace)
|
||||
} else {
|
||||
throw new Error(`Original string not found: ${JSON.stringify(original)}`)
|
||||
}
|
||||
|
||||
if (source.includes(original)) {
|
||||
throw new Error(`More than one original string found`, JSON.stringify(original))
|
||||
}
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
const nodeModulesPath = path.resolve(__dirname, '../../', `node_modules`)
|
||||
|
||||
exports.fixDep = async function fixDep(targetFilePath, pairs) {
|
||||
const filePath = path.resolve(nodeModulesPath, targetFilePath)
|
||||
const source = await fs.readFile(filePath, 'utf-8')
|
||||
const modified = modify(source, pairs)
|
||||
await fs.writeFile(filePath, modified, 'utf-8')
|
||||
}
|
||||
|
||||
async function fixDeps() {
|
||||
for (const fix of [require('./pjax-api').fix, require('./styled-components').fix]) {
|
||||
await fix()
|
||||
}
|
||||
}
|
||||
|
||||
fixDeps()
|
||||
45
scripts/fix-deps/pjax-api.js
Normal file
45
scripts/fix-deps/pjax-api.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
const { fixDep } = require('.')
|
||||
|
||||
const targetFilePath = `pjax-api/dist/pjax-api.js`
|
||||
const pairs = [
|
||||
// Firefox
|
||||
[
|
||||
`void xhr.open(method, requestURL.path, true);`,
|
||||
`void xhr.open(method, requestURL.reference, true);`,
|
||||
],
|
||||
// Firefox
|
||||
[
|
||||
`this.document = this.xhr.responseXML.cloneNode(true);`,
|
||||
`this.document = this.xhr.responseXML;`,
|
||||
],
|
||||
// Chrome: modifying cross-context history state causes troubles
|
||||
// Scroll position can still be restored without this function
|
||||
[
|
||||
`
|
||||
function savePosition() {
|
||||
var _a;
|
||||
void window.history.replaceState({
|
||||
...window.history.state,
|
||||
position: {
|
||||
...(_a = window.history.state) === null || _a === void 0 ? void 0 : _a.position,
|
||||
top: window.pageYOffset,
|
||||
left: window.pageXOffset
|
||||
}
|
||||
}, document.title);
|
||||
}`,
|
||||
`
|
||||
function savePosition() {
|
||||
return;
|
||||
}`,
|
||||
],
|
||||
]
|
||||
|
||||
exports.fix = async () => {
|
||||
try {
|
||||
await fixDep(targetFilePath, pairs)
|
||||
} catch (err) {
|
||||
console.error((err && err.message) || err)
|
||||
const shouldTerminate = process.env.IGNORE_FIX_PJAX_API_FAILURE !== 'true'
|
||||
if (shouldTerminate) process.exit(1)
|
||||
}
|
||||
}
|
||||
20
scripts/fix-deps/styled-components.js
Normal file
20
scripts/fix-deps/styled-components.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const { fixDep } = require('.')
|
||||
|
||||
const targetFilePath = `styled-components/dist/styled-components.browser.esm.js`
|
||||
const pairs = [
|
||||
// Firefox
|
||||
// disable production check in `checkDynamicCreation`
|
||||
[
|
||||
`function(e,t){if("production"!==process.env.NODE_ENV)`, // prettier-ignore
|
||||
`function(e,t){if(false)`,
|
||||
],
|
||||
]
|
||||
|
||||
exports.fix = async () => {
|
||||
try {
|
||||
await fixDep(targetFilePath, pairs)
|
||||
} catch (err) {
|
||||
console.error((err && err.message) || err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* This script rewrites code of pjax-api to resolve compatibility issues.
|
||||
* This is a bit dirty but really effective.
|
||||
*/
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
|
||||
function modify(source = '', pairs = []) {
|
||||
for (const [original, replace] of pairs) {
|
||||
if (source.includes(original)) {
|
||||
source = source.replace(original, replace)
|
||||
} else {
|
||||
throw new Error(`Original string not found: ${JSON.stringify(original)}`)
|
||||
}
|
||||
|
||||
if (source.includes(original)) {
|
||||
throw new Error(`More than one original string found`, JSON.stringify(original))
|
||||
}
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
async function fixPJAXAPI(loose) {
|
||||
const pairs = [
|
||||
// Firefox
|
||||
[
|
||||
`void xhr.open(method, requestURL.path, true);`,
|
||||
`void xhr.open(method, requestURL.reference, true);`,
|
||||
],
|
||||
// Firefox
|
||||
[
|
||||
`this.document = this.xhr.responseXML.cloneNode(true);`,
|
||||
`this.document = this.xhr.responseXML;`,
|
||||
],
|
||||
// Chrome: modifying cross-context history state causes troubles
|
||||
// Scroll position can still be restored without this function
|
||||
[
|
||||
`
|
||||
function savePosition() {
|
||||
var _a;
|
||||
void window.history.replaceState({
|
||||
...window.history.state,
|
||||
position: {
|
||||
...(_a = window.history.state) === null || _a === void 0 ? void 0 : _a.position,
|
||||
top: window.pageYOffset,
|
||||
left: window.pageXOffset
|
||||
}
|
||||
}, document.title);
|
||||
}`,
|
||||
`
|
||||
function savePosition() {
|
||||
return;
|
||||
}`,
|
||||
],
|
||||
]
|
||||
try {
|
||||
const filePath = path.resolve(__dirname, '..', `node_modules/pjax-api/dist/pjax-api.js`)
|
||||
const source = await fs.readFile(filePath, 'utf-8')
|
||||
const modified = modify(source, pairs, loose)
|
||||
await fs.writeFile(filePath, modified, 'utf-8')
|
||||
} catch (err) {
|
||||
console.error((err && err.message) || err)
|
||||
const shouldTerminate = process.env.IGNORE_FIX_PJAX_API_FAILURE !== 'true'
|
||||
if (shouldTerminate) process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
fixPJAXAPI()
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import * as Sentry from '@sentry/browser'
|
||||
import { Middleware } from 'driver/connect.js'
|
||||
import { IN_PRODUCTION_MODE, VERSION } from 'env'
|
||||
import { IN_PRODUCTION_MODE, SENTRY, VERSION } from 'env'
|
||||
import { platform } from 'platforms'
|
||||
import { atomicAsyncFunction } from 'utils/general'
|
||||
import { atomicAsyncFunction, forOf } from 'utils/general'
|
||||
import { storageHelper, storageKeys } from 'utils/storageHelper'
|
||||
|
||||
const PUBLIC_KEY = 'd22ec5c9cc874539a51c78388c12e3b0'
|
||||
const PROJECT_ID = '1406497'
|
||||
const { PUBLIC_KEY, PROJECT_ID } = SENTRY
|
||||
|
||||
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>([
|
||||
|
|
@ -58,19 +56,6 @@ const sentryOptions: Sentry.BrowserOptions = {
|
|||
}
|
||||
Sentry.init(sentryOptions)
|
||||
|
||||
export const withErrorLog: Middleware = function withErrorLog(method, args) {
|
||||
return [
|
||||
async function (...args) {
|
||||
try {
|
||||
await method(...args)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) raiseError(error)
|
||||
}
|
||||
} as typeof method,
|
||||
args,
|
||||
]
|
||||
}
|
||||
|
||||
// 1. Only cache errors for current version, so that future errors can still be exposed
|
||||
// - Run migration to clean on every update
|
||||
|
||||
|
|
@ -89,7 +74,9 @@ const hasTheErrorBeenReported = atomicAsyncFunction(async function hasTheErrorBe
|
|||
|
||||
type ErrorCache = string
|
||||
const cache: ErrorCache[] =
|
||||
(await storageHelper.get(storageKeys.raiseErrorCache))?.[storageKeys.raiseErrorCache] || []
|
||||
((await storageHelper.get(storageKeys.raiseErrorCache))?.[
|
||||
storageKeys.raiseErrorCache
|
||||
] as string[]) || []
|
||||
const has = cache.includes(message)
|
||||
|
||||
if (!has) {
|
||||
|
|
@ -103,7 +90,7 @@ const hasTheErrorBeenReported = atomicAsyncFunction(async function hasTheErrorBe
|
|||
export async function raiseError(
|
||||
error: Error,
|
||||
extra?: {
|
||||
[key: string]: any
|
||||
[key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
) {
|
||||
if (await hasTheErrorBeenReported(error)) return
|
||||
|
|
@ -116,10 +103,8 @@ export async function raiseError(
|
|||
}
|
||||
|
||||
Sentry.withScope(scope => {
|
||||
if (extra) {
|
||||
Object.keys(extra).forEach(key => {
|
||||
scope.setExtra(key, extra[key])
|
||||
})
|
||||
if (typeof extra === 'object' && extra) {
|
||||
forOf(extra, (key, value) => scope.setExtra(`${key}`, value))
|
||||
}
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
|
|
|
|||
7
src/common.d.ts
vendored
Normal file
7
src/common.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Similar to `global.d.ts` but with import/export
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
type ReactIO<T> = {
|
||||
value: T
|
||||
onChange: Dispatch<SetStateAction<T>>
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ import { GitHub } from 'platforms/GitHub'
|
|||
import * as React from 'react'
|
||||
|
||||
export function AccessDeniedDescription() {
|
||||
const configContext = useConfigs()
|
||||
const hasToken = Boolean(configContext.value.accessToken)
|
||||
const { accessToken } = useConfigs().value
|
||||
const hasToken = Boolean(accessToken)
|
||||
|
||||
return (
|
||||
<div className={'description-area'}>
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ export function Clippy({ codeSnippetElement }: Props) {
|
|||
React.useEffect(() => {
|
||||
const element = elementRef.current
|
||||
if (element) {
|
||||
function onClippyClick() {
|
||||
const onClippyClick = () =>
|
||||
setState(copyElementContent(codeSnippetElement) ? 'success' : 'fail')
|
||||
}
|
||||
|
||||
element.addEventListener('click', onClippyClick)
|
||||
return () => element.removeEventListener('click', onClippyClick)
|
||||
}
|
||||
}, [])
|
||||
}, [codeSnippetElement])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
|
|
|||
|
|
@ -1,317 +0,0 @@
|
|||
import { Label, Text } from '@primer/components'
|
||||
import { LoadingIndicator } from 'components/LoadingIndicator'
|
||||
import { Node } from 'components/Node'
|
||||
import { SearchBar } from 'components/SearchBar'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { connect } from 'driver/connect'
|
||||
import { FileExplorerCore } from 'driver/core'
|
||||
import { ConnectorState, Props } from 'driver/core/FileExplorer'
|
||||
import { platform } from 'platforms'
|
||||
import * as React from 'react'
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window'
|
||||
import { cx } from 'utils/cx'
|
||||
import { focusFileExplorer } from 'utils/DOMHelper'
|
||||
import { run } from 'utils/general'
|
||||
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
|
||||
import { useOnLocationChange } from 'utils/hooks/useOnLocationChange'
|
||||
import { useOnPJAXDone } from 'utils/hooks/usePJAX'
|
||||
import { useSequentialEffect } from 'utils/hooks/useSequentialEffect'
|
||||
import { VisibleNodes } from 'utils/VisibleNodesGenerator'
|
||||
import { SideBarStateContext } from '../containers/SideBarState'
|
||||
import { DiffStatGraph } from './DiffStatGraph'
|
||||
import { DiffStatText } from './DiffStatText'
|
||||
import { Icon } from './Icon'
|
||||
import { SearchMode, searchModes } from './searchModes'
|
||||
import { SizeObserver } from './SizeObserver'
|
||||
|
||||
type renderNodeContext = {
|
||||
onNodeClick: (event: React.MouseEvent<HTMLElement, MouseEvent>, node: TreeNode) => void
|
||||
renderLabelText: (node: TreeNode) => React.ReactNode
|
||||
renderActions: ((node: TreeNode) => React.ReactNode) | undefined
|
||||
visibleNodes: VisibleNodes
|
||||
}
|
||||
|
||||
const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplorer(props) {
|
||||
const {
|
||||
visibleNodes,
|
||||
visibleNodesGenerator,
|
||||
freeze,
|
||||
onNodeClick,
|
||||
searchKey,
|
||||
updateSearchKey,
|
||||
onFocusSearchBar,
|
||||
goTo,
|
||||
handleKeyDown,
|
||||
metaData,
|
||||
expandTo,
|
||||
setUpTree,
|
||||
defer,
|
||||
searched,
|
||||
} = props
|
||||
const {
|
||||
value: {
|
||||
accessToken,
|
||||
compressSingletonFolder,
|
||||
searchMode,
|
||||
commentToggle,
|
||||
restoreExpandedFolders,
|
||||
showDiffInText,
|
||||
},
|
||||
} = useConfigs()
|
||||
|
||||
const onSearch = React.useCallback(
|
||||
(searchKey: string, searchMode: SearchMode) => {
|
||||
updateSearchKey(searchKey)
|
||||
if (visibleNodesGenerator) {
|
||||
visibleNodesGenerator.search(
|
||||
searchModes[searchMode].getSearchParams(searchKey),
|
||||
restoreExpandedFolders,
|
||||
)
|
||||
}
|
||||
},
|
||||
[updateSearchKey, visibleNodesGenerator, restoreExpandedFolders],
|
||||
)
|
||||
|
||||
const stateContext = useLoadedContext(SideBarStateContext)
|
||||
const state = stateContext.value
|
||||
|
||||
useSequentialEffect(
|
||||
checker => {
|
||||
setUpTree(
|
||||
{
|
||||
metaData,
|
||||
config: {
|
||||
compressSingletonFolder,
|
||||
accessToken,
|
||||
},
|
||||
stateContext,
|
||||
},
|
||||
checker,
|
||||
)
|
||||
},
|
||||
[setUpTree, metaData, compressSingletonFolder, accessToken],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
focusFileExplorer()
|
||||
}, [])
|
||||
|
||||
const renderActions: ((node: TreeNode) => React.ReactNode) | undefined = React.useMemo(() => {
|
||||
const renderGoToButton = (node: TreeNode): React.ReactNode => (
|
||||
<button
|
||||
title={'Reveal in file tree'}
|
||||
className={'go-to-button'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
goTo(node.path.split('/'))
|
||||
}}
|
||||
>
|
||||
<Icon type="go-to" />
|
||||
</button>
|
||||
)
|
||||
const renderFindInFolderButton = (node: TreeNode): React.ReactNode =>
|
||||
node.type === 'tree' ? (
|
||||
<button
|
||||
title={'Find in folder...'}
|
||||
className={'find-in-folder-button'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onSearch(node.path + '/', searchMode)
|
||||
}}
|
||||
>
|
||||
<Icon type="search" />
|
||||
</button>
|
||||
) : undefined
|
||||
const renderFileCommentAmounts = (node: TreeNode): React.ReactNode =>
|
||||
node.comments?.active ? (
|
||||
<span
|
||||
className={'node-item-comment'}
|
||||
title={`${node.comments.active + node.comments.resolved} comments, ${
|
||||
node.comments.active
|
||||
} active, ${node.comments.resolved} resolved`}
|
||||
>
|
||||
<Icon type={'comment'} /> {node.comments.active > 9 ? '9+' : node.comments.active}
|
||||
</span>
|
||||
) : null
|
||||
const renderFileStatus = ({ diff }: TreeNode): React.ReactNode =>
|
||||
diff && (
|
||||
<span
|
||||
className={'node-item-diff'}
|
||||
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
|
||||
>
|
||||
{showDiffInText ? <DiffStatText diff={diff} /> : <DiffStatGraph diff={diff} />}
|
||||
</span>
|
||||
)
|
||||
|
||||
const renders: ((node: TreeNode) => React.ReactNode)[] = []
|
||||
if (searchMode === 'fuzzy') renders.push(renderFindInFolderButton)
|
||||
if (searched) renders.push(renderGoToButton)
|
||||
if (commentToggle) renders.push(renderFileCommentAmounts)
|
||||
renders.push(renderFileStatus)
|
||||
|
||||
return renders.length
|
||||
? node => renders.map((render, i) => <React.Fragment key={i}>{render(node)}</React.Fragment>)
|
||||
: undefined
|
||||
}, [goTo, onSearch, searched, searchMode, commentToggle, showDiffInText])
|
||||
|
||||
const renderLabelText = React.useCallback(
|
||||
(node: TreeNode) => searchModes[searchMode].renderNodeLabelText(node, searchKey),
|
||||
[searchKey, searchMode],
|
||||
)
|
||||
|
||||
const renderNodeContext: renderNodeContext | null = React.useMemo(
|
||||
() =>
|
||||
visibleNodes && {
|
||||
onNodeClick,
|
||||
renderActions,
|
||||
renderLabelText,
|
||||
visibleNodes,
|
||||
},
|
||||
[onNodeClick, renderActions, renderLabelText, visibleNodes],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cx(`file-explorer`, { freeze })} tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
{run(() => {
|
||||
switch (state) {
|
||||
case 'tree-loading':
|
||||
return <LoadingIndicator text={'Fetching File List...'} />
|
||||
case 'tree-rendering':
|
||||
return <LoadingIndicator text={'Rendering File List...'} />
|
||||
case 'tree-rendered':
|
||||
return (
|
||||
visibleNodes &&
|
||||
renderNodeContext && (
|
||||
<>
|
||||
{defer && (
|
||||
<div className={'status'}>
|
||||
<Label
|
||||
title="This repository is large. Gitako has switched to Lazy Mode to improve performance. Folders will be loaded when it gets expanded."
|
||||
bg="yellow.5"
|
||||
color="gray.6"
|
||||
className={'lazy-mode'}
|
||||
>
|
||||
Lazy Mode is ON
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<SearchBar value={searchKey} onSearch={onSearch} onFocus={onFocusSearchBar} />
|
||||
{searched && visibleNodes.nodes.length === 0 && (
|
||||
<>
|
||||
<Text marginTop={6} textAlign="center" color="text.gray">
|
||||
No results found.
|
||||
</Text>
|
||||
{defer && (
|
||||
<Text textAlign="center" color="gray.4" fontSize="12px">
|
||||
Search results are limited to loaded folders in Lazy Mode.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<SizeObserver className={'files'}>
|
||||
{({ width = 0, height = 0 }) => (
|
||||
<div className={'magic-size-container'}>
|
||||
<ListView
|
||||
height={height}
|
||||
width={width}
|
||||
renderNodeContext={renderNodeContext}
|
||||
expandTo={expandTo}
|
||||
metaData={metaData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SizeObserver>
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
RawFileExplorer.defaultProps = {
|
||||
freeze: false,
|
||||
searchKey: '',
|
||||
visibleNodes: null,
|
||||
}
|
||||
|
||||
export const FileExplorer = connect(FileExplorerCore)(RawFileExplorer)
|
||||
|
||||
const VirtualNode = React.memo(function VirtualNode({
|
||||
index,
|
||||
style,
|
||||
data: { onNodeClick, renderLabelText, renderActions, visibleNodes },
|
||||
}: Override<ListChildComponentProps, { data: renderNodeContext }>) {
|
||||
if (!visibleNodes) return null
|
||||
|
||||
const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes as VisibleNodes
|
||||
const node = nodes[index]
|
||||
|
||||
return (
|
||||
<Node
|
||||
style={style}
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={depths.get(node) || 0}
|
||||
focused={focusedNode?.path === node.path}
|
||||
loading={loading.has(node.path)}
|
||||
expanded={expandedNodes.has(node.path)}
|
||||
onClick={onNodeClick}
|
||||
renderLabelText={renderLabelText}
|
||||
renderActions={renderActions}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
type ListViewProps = {
|
||||
height: number
|
||||
width: number
|
||||
renderNodeContext: renderNodeContext
|
||||
} & Pick<Props, 'metaData'> &
|
||||
Pick<ConnectorState, 'expandTo'>
|
||||
|
||||
function ListView({ width, height, metaData, expandTo, renderNodeContext }: ListViewProps) {
|
||||
const { visibleNodes } = renderNodeContext
|
||||
const { focusedNode, nodes } = visibleNodes
|
||||
const listRef = React.useRef<FixedSizeList>(null)
|
||||
// the change of depths indicates switch into/from search state
|
||||
React.useEffect(() => {
|
||||
if (listRef.current && focusedNode?.path) {
|
||||
const index = nodes.findIndex(node => node.path === focusedNode.path)
|
||||
if (index !== -1) {
|
||||
listRef.current.scrollToItem(index, 'smart')
|
||||
}
|
||||
}
|
||||
}, [focusedNode, nodes])
|
||||
// For some reason, removing the deps array above results in bug:
|
||||
// If scroll fast and far, then clicking on items would result in redirect
|
||||
// Not know the reason :(
|
||||
|
||||
const goToCurrentItem = React.useCallback(() => {
|
||||
const targetPath = platform.getCurrentPath(metaData.branchName)
|
||||
if (targetPath) expandTo(targetPath)
|
||||
}, [metaData.branchName])
|
||||
|
||||
useOnLocationChange(goToCurrentItem)
|
||||
useOnPJAXDone(goToCurrentItem)
|
||||
|
||||
const { compactFileTree } = useConfigs().value
|
||||
|
||||
return (
|
||||
<FixedSizeList
|
||||
ref={listRef}
|
||||
itemKey={(index, { visibleNodes }) => visibleNodes?.nodes[index]?.path}
|
||||
itemData={renderNodeContext}
|
||||
itemCount={visibleNodes.nodes.length}
|
||||
itemSize={compactFileTree ? 24 : 37}
|
||||
height={height}
|
||||
width={width}
|
||||
overscanCount={20}
|
||||
>
|
||||
{VirtualNode}
|
||||
</FixedSizeList>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,21 @@
|
|||
import {
|
||||
DiffAddedIcon,
|
||||
DiffIgnoredIcon,
|
||||
DiffModifiedIcon,
|
||||
DiffRemovedIcon,
|
||||
DiffRenamedIcon,
|
||||
} from '@primer/octicons-react'
|
||||
import * as React from 'react'
|
||||
import { resolveDiffGraphMeta } from 'utils/general'
|
||||
import { Icon } from './Icon'
|
||||
import { Icon } from '../Icon'
|
||||
|
||||
const iconMap = {
|
||||
added: DiffAddedIcon,
|
||||
ignored: DiffIgnoredIcon,
|
||||
modified: DiffModifiedIcon,
|
||||
removed: DiffRemovedIcon,
|
||||
renamed: DiffRenamedIcon,
|
||||
}
|
||||
|
||||
export function DiffStatGraph({
|
||||
diff: { status, changes, additions, deletions },
|
||||
|
|
@ -19,18 +34,7 @@ export function DiffStatGraph({
|
|||
|
||||
return (
|
||||
<span className={'diff-stat-graph'}>
|
||||
<Icon
|
||||
className={status}
|
||||
type={
|
||||
{
|
||||
added: 'diffAdded',
|
||||
ignored: 'diffIgnored',
|
||||
modified: 'diffModified',
|
||||
removed: 'diffRemoved',
|
||||
renamed: 'diffRenamed',
|
||||
}[status]
|
||||
}
|
||||
/>
|
||||
<Icon className={status} IconComponent={iconMap[status]} />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { Icon } from '../Icon'
|
||||
|
||||
export function DiffStatText({
|
||||
diff: { status, changes, additions, deletions },
|
||||
diff: { status, additions, deletions },
|
||||
}: {
|
||||
diff: Required<TreeNode>['diff']
|
||||
}) {
|
||||
95
src/components/FileExplorer/ListView.tsx
Normal file
95
src/components/FileExplorer/ListView.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { useStateIO } from 'utils/hooks/useStateIO'
|
||||
import { NodeRendererContext } from '.'
|
||||
import { Node } from './Node'
|
||||
import { AlignMode, useVirtualScroll } from './useVirtualScroll'
|
||||
|
||||
type ListViewProps = {
|
||||
height: number
|
||||
width: number
|
||||
nodeRendererContext: NodeRendererContext
|
||||
}
|
||||
|
||||
export function ListView({ width, height, nodeRendererContext }: ListViewProps) {
|
||||
const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } =
|
||||
nodeRendererContext
|
||||
const { focusedNode, nodes, expandedNodes, depths, loading } = visibleNodes
|
||||
|
||||
const { compactFileTree } = useConfigs().value
|
||||
|
||||
const rowHeight = compactFileTree ? 24 : 37
|
||||
const totalAmount = visibleNodes.nodes.length
|
||||
const { onScroll, visibleRows, containerStyle, scrollToItem, ref } =
|
||||
useVirtualScroll<HTMLDivElement>({
|
||||
totalAmount,
|
||||
rowHeight,
|
||||
viewportHeight: height,
|
||||
overScan: 10,
|
||||
})
|
||||
|
||||
const $mode = useStateIO<AlignMode>('top')
|
||||
const enableScroll = width * height > 0 // these can be 0 on first render
|
||||
|
||||
const index = React.useMemo(
|
||||
() =>
|
||||
width && height && focusedNode?.path
|
||||
? nodes.findIndex(node => node.path === focusedNode.path)
|
||||
: -1,
|
||||
[focusedNode?.path, nodes, width, height],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
// - init loading
|
||||
// - "top"
|
||||
// - NO immediate call
|
||||
// - jump to file
|
||||
// - "top"
|
||||
// - NO immediate call
|
||||
// - click file/folder
|
||||
// - not invoke
|
||||
// - navigate with keyboard
|
||||
// - "lazy"
|
||||
// - immediate call
|
||||
if (enableScroll && index !== -1) {
|
||||
scrollToItem?.(index, $mode.value)
|
||||
}
|
||||
}, [enableScroll, $mode.value, index, scrollToItem])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enableScroll && $mode.value === 'top') $mode.onChange('lazy')
|
||||
}, [enableScroll, $mode.value]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
ref={ref}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<div style={containerStyle}>
|
||||
{visibleRows.map(({ row, style }) => {
|
||||
const node = nodes[row]
|
||||
return (
|
||||
<Node
|
||||
key={node.path}
|
||||
node={node}
|
||||
style={style}
|
||||
depth={depths.get(node) || 0}
|
||||
focused={focusedNode?.path === node.path}
|
||||
loading={loading.has(node.path)}
|
||||
expanded={expandedNodes.has(node.path)}
|
||||
onClick={onNodeClick}
|
||||
onFocus={onNodeFocus}
|
||||
renderLabelText={renderLabelText}
|
||||
renderActions={renderActions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { platform } from 'platforms'
|
|||
import * as React from 'react'
|
||||
import { cx } from 'utils/cx'
|
||||
import { getFileIconURL, getFolderIconURL } from 'utils/parseIconMapCSV'
|
||||
import { Icon } from './Icon'
|
||||
import { Icon } from '../Icon'
|
||||
|
||||
function getIconType(node: TreeNode) {
|
||||
switch (node.type) {
|
||||
|
|
@ -19,6 +19,7 @@ function getIconType(node: TreeNode) {
|
|||
type Props = {
|
||||
node: TreeNode
|
||||
onClick(event: React.MouseEvent<HTMLElement, MouseEvent>, node: TreeNode): void
|
||||
onFocus(event: React.FocusEvent<HTMLElement, Element>, node: TreeNode): void
|
||||
depth: number
|
||||
expanded: boolean
|
||||
focused: boolean
|
||||
|
|
@ -27,7 +28,8 @@ type Props = {
|
|||
renderLabelText(node: TreeNode): React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
export function Node({
|
||||
|
||||
export const Node = React.memo(function Node({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
|
|
@ -37,17 +39,20 @@ export function Node({
|
|||
renderLabelText,
|
||||
style,
|
||||
onClick,
|
||||
onFocus,
|
||||
}: Props) {
|
||||
const { compactFileTree: compact } = useConfigs().value
|
||||
return (
|
||||
<a
|
||||
href={node.url}
|
||||
onClick={event => onClick(event, node)}
|
||||
onFocus={event => onFocus(event, node)}
|
||||
className={cx(`node-item`, { focused, disabled: node.accessDenied, expanded, compact })}
|
||||
style={{ ...style, paddingLeft: `${10 + (compact ? 10 : 20) * depth}px` }}
|
||||
title={node.path}
|
||||
target={node.type === 'commit' ? '_blank' : undefined}
|
||||
{...platform.delegatePJAXProps?.({ node })}
|
||||
rel="noopener noreferrer"
|
||||
{...platform.delegateFastRedirectAnchorProps?.({ node })}
|
||||
>
|
||||
<div className={'node-item-label'}>
|
||||
<NodeItemIcon node={node} open={expanded} loading={loading} />
|
||||
|
|
@ -56,7 +61,7 @@ export function Node({
|
|||
{renderActions && <div className={'actions'}>{renderActions(node)}</div>}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const NodeItemIcon = React.memo(function NodeItemIcon({
|
||||
node,
|
||||
|
|
@ -67,25 +72,24 @@ const NodeItemIcon = React.memo(function NodeItemIcon({
|
|||
open?: boolean
|
||||
loading?: boolean
|
||||
}) {
|
||||
const {
|
||||
value: { icons },
|
||||
} = useConfigs()
|
||||
const { icons } = useConfigs().value
|
||||
|
||||
const src = React.useMemo(
|
||||
() => (node.type === 'tree' ? getFolderIconURL(node, open) : getFileIconURL(node)),
|
||||
[open],
|
||||
[node, open],
|
||||
)
|
||||
const iconType = React.useMemo(() => getIconType(node), [node])
|
||||
|
||||
if (icons === 'native') return <Icon type={getIconType(node)} />
|
||||
if (icons === 'native') return <Icon type={iconType} />
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
className={'node-item-type-icon'}
|
||||
placeholder={node.type !== 'tree'}
|
||||
type={loading ? 'loading' : getIconType(node)}
|
||||
type={loading ? 'loading' : iconType}
|
||||
/>
|
||||
{node.type === 'commit' ? (
|
||||
<Icon type={getIconType(node)} />
|
||||
<Icon type={iconType} />
|
||||
) : (
|
||||
<img alt={node.name} className={cx('node-item-icon', { dim: icons === 'dim' })} src={src} />
|
||||
)}
|
||||
32
src/components/FileExplorer/VirtualNode.tsx
Normal file
32
src/components/FileExplorer/VirtualNode.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Node } from 'components/FileExplorer/Node'
|
||||
import * as React from 'react'
|
||||
import { ListChildComponentProps } from 'react-window'
|
||||
import { NodeRendererContext } from '.'
|
||||
|
||||
export const VirtualNode = React.memo(function VirtualNode({
|
||||
index,
|
||||
style,
|
||||
data,
|
||||
}: Override<ListChildComponentProps, { data: NodeRendererContext }>) {
|
||||
const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } = data
|
||||
if (!visibleNodes) return null
|
||||
|
||||
const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes
|
||||
const node = nodes[index]
|
||||
|
||||
return (
|
||||
<Node
|
||||
style={style}
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={depths.get(node) || 0}
|
||||
focused={focusedNode?.path === node.path}
|
||||
loading={loading.has(node.path)}
|
||||
expanded={expandedNodes.has(node.path)}
|
||||
onClick={onNodeClick}
|
||||
onFocus={onNodeFocus}
|
||||
renderLabelText={renderLabelText}
|
||||
renderActions={renderActions}
|
||||
/>
|
||||
)
|
||||
})
|
||||
12
src/components/FileExplorer/hooks/useExpandTo.tsx
Normal file
12
src/components/FileExplorer/hooks/useExpandTo.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import * as React from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
|
||||
export function useExpandTo(visibleNodesGenerator: VisibleNodesGenerator) {
|
||||
return React.useCallback(
|
||||
async (currentPath: string[]) => {
|
||||
const nodeExpandedTo = await visibleNodesGenerator.expandTo(currentPath.join('/'))
|
||||
if (nodeExpandedTo) visibleNodesGenerator.focusNode(nodeExpandedTo)
|
||||
},
|
||||
[visibleNodesGenerator],
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import * as React from 'react'
|
||||
import * as DOMHelper from 'utils/DOMHelper'
|
||||
|
||||
export function useFocusFileExplorerOnFirstRender() {
|
||||
React.useEffect(() => {
|
||||
DOMHelper.focusFileExplorer()
|
||||
}, [])
|
||||
}
|
||||
9
src/components/FileExplorer/hooks/useFocusNode.tsx
Normal file
9
src/components/FileExplorer/hooks/useFocusNode.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import * as React from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
|
||||
export function useFocusNode(visibleNodesGenerator: VisibleNodesGenerator) {
|
||||
return React.useCallback(
|
||||
(node: TreeNode | null) => visibleNodesGenerator.focusNode(node),
|
||||
[visibleNodesGenerator],
|
||||
)
|
||||
}
|
||||
6
src/components/FileExplorer/hooks/useGetCurrentPath.tsx
Normal file
6
src/components/FileExplorer/hooks/useGetCurrentPath.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { platform } from 'platforms'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export function useGetCurrentPath({ branchName }: MetaData) {
|
||||
return useCallback(() => platform.getCurrentPath(branchName), [branchName])
|
||||
}
|
||||
18
src/components/FileExplorer/hooks/useGoTo.tsx
Normal file
18
src/components/FileExplorer/hooks/useGoTo.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
import { useExpandTo } from './useExpandTo'
|
||||
|
||||
export function useGoTo(
|
||||
visibleNodesGenerator: VisibleNodesGenerator,
|
||||
updateSearchKey: React.Dispatch<React.SetStateAction<string>>,
|
||||
expandTo: ReturnType<typeof useExpandTo>,
|
||||
) {
|
||||
return React.useCallback(
|
||||
(path: string[]) => {
|
||||
updateSearchKey('')
|
||||
visibleNodesGenerator.search(null)
|
||||
visibleNodesGenerator.onNextUpdate(() => expandTo(path))
|
||||
},
|
||||
[visibleNodesGenerator, updateSearchKey, expandTo],
|
||||
)
|
||||
}
|
||||
157
src/components/FileExplorer/hooks/useHandleKeyDown.tsx
Normal file
157
src/components/FileExplorer/hooks/useHandleKeyDown.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import * as React from 'react'
|
||||
import * as DOMHelper from 'utils/DOMHelper'
|
||||
import { OperatingSystems, os } from 'utils/general'
|
||||
import { loadWithFastRedirect } from 'utils/hooks/useFastRedirect'
|
||||
import { VisibleNodes } from 'utils/VisibleNodesGenerator'
|
||||
import { AlignMode } from '../useVirtualScroll'
|
||||
import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods'
|
||||
|
||||
function wouldBlockHistoryNavigation(event: React.KeyboardEvent) {
|
||||
// Cmd + left/right on macOS
|
||||
// Alt + left/right on other OSes
|
||||
return (
|
||||
(os === OperatingSystems.macOS && event.metaKey) ||
|
||||
(os !== OperatingSystems.macOS && event.altKey)
|
||||
)
|
||||
}
|
||||
|
||||
function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode) {
|
||||
let index = nodes.findIndex(node => node.path === focusedNode.path) - 1
|
||||
while (index >= 0) {
|
||||
if (nodes[index].contents?.includes(focusedNode)) {
|
||||
return nodes[index]
|
||||
}
|
||||
--index
|
||||
}
|
||||
}
|
||||
|
||||
export function useHandleKeyDown(
|
||||
visibleNodes: VisibleNodes,
|
||||
{ focusNode, toggleExpansion, goTo }: VisibleNodesGeneratorMethods,
|
||||
searched: boolean,
|
||||
setAlignMode: (mode: AlignMode) => void,
|
||||
) {
|
||||
return React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
const { nodes, focusedNode, expandedNodes } = visibleNodes
|
||||
|
||||
const handleVerticalMove = (index: number) => {
|
||||
if (0 <= index && index < nodes.length) {
|
||||
DOMHelper.focusFileExplorer()
|
||||
setAlignMode('lazy')
|
||||
focusNode(nodes[index])
|
||||
} else {
|
||||
DOMHelper.focusSearchInput()
|
||||
focusNode(null)
|
||||
}
|
||||
}
|
||||
|
||||
const { key } = event
|
||||
// prevent document body scrolling if the keypress results in Gitako action
|
||||
let muteEvent = true
|
||||
if (focusedNode) {
|
||||
const focusedNodeIndex = nodes.findIndex(node => node.path === focusedNode.path)
|
||||
switch (key) {
|
||||
case 'ArrowUp':
|
||||
// focus on previous node
|
||||
handleVerticalMove(focusedNodeIndex - 1)
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
// focus on next node
|
||||
handleVerticalMove(focusedNodeIndex + 1)
|
||||
break
|
||||
|
||||
case 'ArrowLeft':
|
||||
if (wouldBlockHistoryNavigation(event)) {
|
||||
muteEvent = false
|
||||
break
|
||||
}
|
||||
if (expandedNodes.has(focusedNode.path)) {
|
||||
toggleExpansion(focusedNode, { recursive: event.altKey })
|
||||
setAlignMode('lazy')
|
||||
} else {
|
||||
// go forward to the start of the list, find the closest node with lower depth
|
||||
const parentNode = getVisibleParentNode(nodes, focusedNode)
|
||||
if (parentNode) {
|
||||
focusNode(parentNode)
|
||||
setAlignMode('lazy')
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
// consider the two keys as 'confirm' key
|
||||
case 'ArrowRight':
|
||||
if (wouldBlockHistoryNavigation(event)) {
|
||||
muteEvent = false
|
||||
break
|
||||
}
|
||||
// expand node or focus on first content node or redirect to file page
|
||||
if (focusedNode.type === 'tree') {
|
||||
if (expandedNodes.has(focusedNode.path)) {
|
||||
const nextNode = nodes[focusedNodeIndex + 1]
|
||||
if (focusedNode.contents?.includes(nextNode)) {
|
||||
focusNode(nextNode)
|
||||
setAlignMode('lazy')
|
||||
}
|
||||
} else {
|
||||
toggleExpansion(focusedNode, { recursive: event.altKey })
|
||||
}
|
||||
} else if (focusedNode.type === 'blob') {
|
||||
const focusedNodeElement = DOMHelper.findNodeElement(focusedNode, event.currentTarget)
|
||||
if (focusedNodeElement && focusedNode.url)
|
||||
loadWithFastRedirect(focusedNode.url, focusedNodeElement)
|
||||
} else if (focusedNode.type === 'commit') {
|
||||
window.open(focusedNode.url)
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
// expand node or redirect to file page
|
||||
if (searched) {
|
||||
goTo(focusedNode.path.split('/'))
|
||||
setAlignMode('top')
|
||||
} else {
|
||||
if (focusedNode.type === 'tree') {
|
||||
toggleExpansion(focusedNode, { recursive: event.altKey })
|
||||
} else if (focusedNode.type === 'blob') {
|
||||
const focusedNodeElement = DOMHelper.findNodeElement(
|
||||
focusedNode,
|
||||
event.currentTarget,
|
||||
)
|
||||
if (focusedNodeElement && focusedNode.url)
|
||||
loadWithFastRedirect(focusedNode.url, focusedNodeElement)
|
||||
} else if (focusedNode.type === 'commit') {
|
||||
window.open(focusedNode.url)
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
muteEvent = false
|
||||
}
|
||||
if (muteEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
} else {
|
||||
// now search input is focused
|
||||
if (nodes.length) {
|
||||
switch (key) {
|
||||
case 'ArrowDown':
|
||||
DOMHelper.focusFileExplorer()
|
||||
focusNode(nodes[0])
|
||||
break
|
||||
case 'ArrowUp':
|
||||
DOMHelper.focusFileExplorer()
|
||||
focusNode(nodes[nodes.length - 1])
|
||||
break
|
||||
default:
|
||||
muteEvent = false
|
||||
}
|
||||
if (muteEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[visibleNodes, searched, goTo, focusNode, toggleExpansion, setAlignMode],
|
||||
)
|
||||
}
|
||||
105
src/components/FileExplorer/hooks/useNodeRenderers.tsx
Normal file
105
src/components/FileExplorer/hooks/useNodeRenderers.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { CommentIcon } from '@primer/octicons-react'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { is } from 'utils/is'
|
||||
import { Icon } from '../../Icon'
|
||||
import { SearchMode } from '../../searchModes'
|
||||
import { DiffStatGraph } from './../DiffStatGraph'
|
||||
|
||||
export type NodeRenderer = (node: TreeNode) => React.ReactNode
|
||||
|
||||
export function useNodeRenderers(allRenderers: (NodeRenderer | null | undefined)[]) {
|
||||
return React.useMemo(() => {
|
||||
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>)
|
||||
: undefined
|
||||
}, allRenderers) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
export function useRenderFileStatus() {
|
||||
function renderFileStatus({ diff }: TreeNode) {
|
||||
return (
|
||||
diff && (
|
||||
<span
|
||||
className={'node-item-diff'}
|
||||
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
|
||||
>
|
||||
<DiffStatGraph diff={diff} />
|
||||
</span>
|
||||
)
|
||||
)
|
||||
}
|
||||
return React.useMemo(() => renderFileStatus, [])
|
||||
}
|
||||
|
||||
export function useRenderFileCommentAmounts() {
|
||||
function renderFileCommentAmounts(node: TreeNode) {
|
||||
return node.comments?.active ? (
|
||||
<span
|
||||
className={'node-item-comment'}
|
||||
title={`${node.comments.active + node.comments.resolved} comments, ${
|
||||
node.comments.active
|
||||
} active, ${node.comments.resolved} resolved`}
|
||||
>
|
||||
<Icon IconComponent={CommentIcon} />
|
||||
|
||||
{node.comments.active > 9 ? '9+' : node.comments.active}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
const { commentToggle } = useConfigs().value
|
||||
return React.useMemo(() => (commentToggle ? renderFileCommentAmounts : null), [commentToggle])
|
||||
}
|
||||
|
||||
export function useRenderFindInFolderButton(
|
||||
onSearch: (searchKey: string, searchMode: SearchMode) => void,
|
||||
) {
|
||||
const { searchMode } = useConfigs().value
|
||||
return React.useMemo(
|
||||
() =>
|
||||
searchMode === 'fuzzy'
|
||||
? function renderFindInFolderButton(node: TreeNode) {
|
||||
return node.type === 'tree' ? (
|
||||
<button
|
||||
title={'Find in folder...'}
|
||||
className={'find-in-folder-button'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onSearch(node.path + '/', searchMode)
|
||||
}}
|
||||
>
|
||||
<Icon type="search" />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
: null,
|
||||
[searchMode, onSearch],
|
||||
)
|
||||
}
|
||||
|
||||
export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) => void) {
|
||||
return React.useMemo(
|
||||
() =>
|
||||
searched
|
||||
? function renderGoToButton(node: TreeNode): React.ReactNode {
|
||||
return (
|
||||
<button
|
||||
title={'Reveal in file tree'}
|
||||
className={'go-to-button'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
goTo(node.path.split('/'))
|
||||
}}
|
||||
>
|
||||
<Icon type="go-to" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
: null,
|
||||
[searched, goTo],
|
||||
)
|
||||
}
|
||||
50
src/components/FileExplorer/hooks/useOnNodeClick.tsx
Normal file
50
src/components/FileExplorer/hooks/useOnNodeClick.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { isOpenInNewWindowClick } from 'utils/general'
|
||||
import { loadWithFastRedirect } from 'utils/hooks/useFastRedirect'
|
||||
import { AlignMode } from '../useVirtualScroll'
|
||||
import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods'
|
||||
|
||||
export function useHandleNodeClick(
|
||||
{ toggleExpansion, focusNode }: VisibleNodesGeneratorMethods,
|
||||
setAlignMode: (mode: AlignMode) => void,
|
||||
) {
|
||||
const { recursiveToggleFolder } = useConfigs().value
|
||||
return React.useCallback(
|
||||
(event: React.MouseEvent<HTMLElement, MouseEvent>, node: TreeNode) => {
|
||||
setAlignMode('lazy')
|
||||
switch (node.type) {
|
||||
case 'tree': {
|
||||
const recursive =
|
||||
(recursiveToggleFolder === 'shift' && event.shiftKey) ||
|
||||
(recursiveToggleFolder === 'alt' && event.altKey)
|
||||
// recursive toggle action may conflict with browser default action
|
||||
// e.g. shift + click is the default open in new tab action on macOS
|
||||
// giving recursive toggle action higher priority than default action
|
||||
if (!recursive && isOpenInNewWindowClick(event)) return
|
||||
|
||||
event.preventDefault()
|
||||
toggleExpansion(node, { recursive })
|
||||
break
|
||||
}
|
||||
case 'blob': {
|
||||
if (isOpenInNewWindowClick(event)) return
|
||||
|
||||
focusNode(node)
|
||||
if (node.url) {
|
||||
const isHashLink = node.url.includes('#')
|
||||
if (!isHashLink) {
|
||||
event.preventDefault()
|
||||
loadWithFastRedirect(node.url, event.currentTarget)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'commit': {
|
||||
// pass event, open in new tab thanks to the target="_blank" on the anchor element
|
||||
}
|
||||
}
|
||||
},
|
||||
[toggleExpansion, recursiveToggleFolder, focusNode, setAlignMode],
|
||||
)
|
||||
}
|
||||
21
src/components/FileExplorer/hooks/useOnSearch.tsx
Normal file
21
src/components/FileExplorer/hooks/useOnSearch.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
import { SearchMode, searchModes } from '../../searchModes'
|
||||
|
||||
export function useOnSearch(
|
||||
updateSearchKey: (searchKey: string) => void,
|
||||
visibleNodesGenerator: VisibleNodesGenerator,
|
||||
) {
|
||||
const { restoreExpandedFolders } = useConfigs().value
|
||||
return React.useCallback(
|
||||
(searchKey: string, searchMode: SearchMode) => {
|
||||
updateSearchKey(searchKey)
|
||||
visibleNodesGenerator.search(
|
||||
searchModes[searchMode].getSearchParams(searchKey),
|
||||
restoreExpandedFolders,
|
||||
)
|
||||
},
|
||||
[updateSearchKey, visibleNodesGenerator, restoreExpandedFolders],
|
||||
)
|
||||
}
|
||||
11
src/components/FileExplorer/hooks/useRenderLabelText.tsx
Normal file
11
src/components/FileExplorer/hooks/useRenderLabelText.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { searchModes } from '../../searchModes'
|
||||
|
||||
export function useRenderLabelText(searchKey: string) {
|
||||
const { searchMode } = useConfigs().value
|
||||
return React.useCallback(
|
||||
(node: TreeNode) => searchModes[searchMode].renderNodeLabelText(node, searchKey),
|
||||
[searchKey, searchMode],
|
||||
)
|
||||
}
|
||||
21
src/components/FileExplorer/hooks/useToggleExpansion.tsx
Normal file
21
src/components/FileExplorer/hooks/useToggleExpansion.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
|
||||
export function useToggleExpansion(visibleNodesGenerator: VisibleNodesGenerator) {
|
||||
return React.useCallback(
|
||||
async (
|
||||
node: TreeNode,
|
||||
{
|
||||
recursive = false,
|
||||
}: {
|
||||
recursive?: boolean
|
||||
},
|
||||
) => {
|
||||
if (node.type === 'tree') {
|
||||
visibleNodesGenerator.focusNode(node)
|
||||
await visibleNodesGenerator.toggleExpand(node, recursive)
|
||||
}
|
||||
},
|
||||
[visibleNodesGenerator],
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { platform } from 'platforms'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCatchNetworkError } from 'utils/hooks/useCatchNetworkError'
|
||||
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
|
||||
import { useSequentialEffect } from 'utils/hooks/useSequentialEffect'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
import { SideBarStateContext } from '../../../containers/SideBarState'
|
||||
|
||||
export function useVisibleNodesGenerator(metaData: MetaData | null) {
|
||||
const [visibleNodesGenerator, setVisibleNodesGenerator] = useState<VisibleNodesGenerator | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const catchNetworkErrors = useCatchNetworkError()
|
||||
const config = useConfigs().value
|
||||
const setStateContext = useLoadedContext(SideBarStateContext).onChange
|
||||
|
||||
// Only run when metadata or accessToken changes
|
||||
useSequentialEffect(
|
||||
useCallback(
|
||||
shouldAbort => {
|
||||
catchNetworkErrors(async () => {
|
||||
if (!metaData) return
|
||||
if (shouldAbort()) return
|
||||
|
||||
setStateContext('tree-loading')
|
||||
const { userName, repoName, branchName } = metaData
|
||||
const { root: treeRoot, defer = false } = await platform.getTreeData(
|
||||
{
|
||||
branchName,
|
||||
userName,
|
||||
repoName,
|
||||
},
|
||||
'/',
|
||||
true,
|
||||
config.accessToken,
|
||||
)
|
||||
if (shouldAbort()) return
|
||||
|
||||
setStateContext('tree-rendering')
|
||||
|
||||
setVisibleNodesGenerator(
|
||||
new VisibleNodesGenerator({
|
||||
root: treeRoot,
|
||||
defer,
|
||||
compress: config.compressSingletonFolder,
|
||||
async getTreeData(path) {
|
||||
const { root } = await platform.getTreeData(
|
||||
metaData,
|
||||
path,
|
||||
false,
|
||||
config.accessToken,
|
||||
)
|
||||
return root
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
setStateContext('tree-rendered')
|
||||
})
|
||||
},
|
||||
[metaData, config.accessToken], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
),
|
||||
)
|
||||
|
||||
return visibleNodesGenerator
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { platform } from 'platforms'
|
||||
import { useEffect } from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
import { useExpandTo } from './useExpandTo'
|
||||
import { useFocusNode } from './useFocusNode'
|
||||
import { useGoTo } from './useGoTo'
|
||||
import { useToggleExpansion } from './useToggleExpansion'
|
||||
|
||||
export function useVisibleNodesGeneratorMethods(
|
||||
visibleNodesGenerator: VisibleNodesGenerator,
|
||||
getCurrentPath: () => string[] | null,
|
||||
updateSearchKey: React.Dispatch<React.SetStateAction<string>>,
|
||||
) {
|
||||
const expandTo = useExpandTo(visibleNodesGenerator)
|
||||
const goTo = useGoTo(visibleNodesGenerator, updateSearchKey, expandTo)
|
||||
const toggleExpansion = useToggleExpansion(visibleNodesGenerator)
|
||||
const focusNode = useFocusNode(visibleNodesGenerator)
|
||||
|
||||
// Only run when visibleNodesGenerator changes
|
||||
// Confirmed: other items in deps array also only update when that changes
|
||||
useEffect(() => {
|
||||
if (platform.shouldExpandAll?.()) {
|
||||
visibleNodesGenerator.onNextUpdate(visibleNodes =>
|
||||
visibleNodes.nodes.forEach(node => toggleExpansion(node, { recursive: true })),
|
||||
)
|
||||
} else {
|
||||
const targetPath = getCurrentPath()
|
||||
if (targetPath) goTo(targetPath)
|
||||
}
|
||||
}, [visibleNodesGenerator, getCurrentPath, goTo, toggleExpansion])
|
||||
|
||||
return {
|
||||
expandTo,
|
||||
goTo,
|
||||
toggleExpansion,
|
||||
focusNode,
|
||||
}
|
||||
}
|
||||
|
||||
export type VisibleNodesGeneratorMethods = ReturnType<typeof useVisibleNodesGeneratorMethods>
|
||||
234
src/components/FileExplorer/index.tsx
Normal file
234
src/components/FileExplorer/index.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { Label, Text } from '@primer/react'
|
||||
import { LoadingIndicator } from 'components/LoadingIndicator'
|
||||
import { SearchBar } from 'components/SearchBar'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { RepoContext } from 'containers/RepoContext'
|
||||
import { platform } from 'platforms'
|
||||
import * as React from 'react'
|
||||
import { usePrevious } from 'react-use'
|
||||
import { cx } from 'utils/cx'
|
||||
import { run } from 'utils/general'
|
||||
import { useElementSize } from 'utils/hooks/useElementSize'
|
||||
import { useAfterRedirect } from 'utils/hooks/useFastRedirect'
|
||||
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
|
||||
import { useOnLocationChange } from 'utils/hooks/useOnLocationChange'
|
||||
import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
import { SideBarStateContext } from '../../containers/SideBarState'
|
||||
import { useFocusFileExplorerOnFirstRender } from './hooks/useFocusFileExplorerOnFirstRender'
|
||||
import { useGetCurrentPath } from './hooks/useGetCurrentPath'
|
||||
import { useHandleKeyDown } from './hooks/useHandleKeyDown'
|
||||
import {
|
||||
NodeRenderer,
|
||||
useNodeRenderers,
|
||||
useRenderFileCommentAmounts,
|
||||
useRenderFileStatus,
|
||||
useRenderFindInFolderButton,
|
||||
useRenderGoToButton
|
||||
} from './hooks/useNodeRenderers'
|
||||
import { useHandleNodeClick } from './hooks/useOnNodeClick'
|
||||
import { useOnSearch } from './hooks/useOnSearch'
|
||||
import { useRenderLabelText } from './hooks/useRenderLabelText'
|
||||
import { useVisibleNodesGenerator } from './hooks/useVisibleNodesGenerator'
|
||||
import { useVisibleNodesGeneratorMethods } from './hooks/useVisibleNodesGeneratorMethods'
|
||||
import { Node } from './Node'
|
||||
import { useHandleNodeFocus } from './useHandleNodeFocus'
|
||||
import { AlignMode, useVirtualScroll } from './useVirtualScroll'
|
||||
import { useVisibleNodes } from './useVisibleNodes'
|
||||
|
||||
export type NodeRendererContext = {
|
||||
onNodeClick: (event: React.MouseEvent<HTMLElement, MouseEvent>, node: TreeNode) => void
|
||||
onNodeFocus: (event: React.FocusEvent<HTMLElement, Element>, node: TreeNode) => void
|
||||
renderLabelText: NodeRenderer
|
||||
renderActions: NodeRenderer | undefined
|
||||
visibleNodes: VisibleNodes
|
||||
}
|
||||
|
||||
export function FileExplorer() {
|
||||
const metaData = React.useContext(RepoContext)
|
||||
const visibleNodesGenerator = useVisibleNodesGenerator(metaData)
|
||||
const visibleNodes = useVisibleNodes(visibleNodesGenerator)
|
||||
const state = useLoadedContext(SideBarStateContext).value
|
||||
|
||||
return (
|
||||
<>
|
||||
{run(() => {
|
||||
switch (state) {
|
||||
case 'tree-loading':
|
||||
return <LoadingIndicator text={'Fetching File List...'} />
|
||||
case 'tree-rendering':
|
||||
return <LoadingIndicator text={'Rendering File List...'} />
|
||||
case 'tree-rendered':
|
||||
return (
|
||||
metaData &&
|
||||
visibleNodesGenerator &&
|
||||
visibleNodes && (
|
||||
<LoadedFileExplorer
|
||||
metaData={metaData}
|
||||
visibleNodesGenerator={visibleNodesGenerator}
|
||||
visibleNodes={visibleNodes}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadedFileExplorer({
|
||||
metaData,
|
||||
visibleNodesGenerator,
|
||||
visibleNodes,
|
||||
}: {
|
||||
metaData: MetaData
|
||||
visibleNodesGenerator: VisibleNodesGenerator
|
||||
visibleNodes: VisibleNodes
|
||||
}) {
|
||||
const [searchKey, updateSearchKey] = React.useState('')
|
||||
const searched = !!searchKey
|
||||
const onSearch = useOnSearch(updateSearchKey, visibleNodesGenerator)
|
||||
const { focusedNode, nodes, expandedNodes, depths, loading } = visibleNodes
|
||||
|
||||
const {
|
||||
ref: filesRef,
|
||||
size: [, height],
|
||||
} = useElementSize<HTMLDivElement>()
|
||||
const { compactFileTree } = useConfigs().value
|
||||
const {
|
||||
ref: scrollElementRef,
|
||||
onScroll,
|
||||
visibleRows,
|
||||
containerStyle,
|
||||
scrollToItem,
|
||||
} = useVirtualScroll<HTMLDivElement>({
|
||||
totalAmount: visibleNodes.nodes.length,
|
||||
rowHeight: compactFileTree ? 24 : 37,
|
||||
viewportHeight: height,
|
||||
overScan: 10,
|
||||
})
|
||||
|
||||
// - init loading
|
||||
// - "top"
|
||||
// - jump to file
|
||||
// - "top"
|
||||
// - tab to file
|
||||
// - "lazy"
|
||||
// - click file/folder
|
||||
// - "lazy"
|
||||
// - navigate with keyboard
|
||||
// - "lazy"
|
||||
const [alignMode, setAlignMode] = React.useState<AlignMode>('top')
|
||||
|
||||
const index = React.useMemo(
|
||||
() => (focusedNode?.path ? nodes.findIndex(node => node.path === focusedNode.path) : -1),
|
||||
[focusedNode?.path, nodes],
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (index !== -1) scrollToItem(index, alignMode)
|
||||
}, [index, scrollToItem, alignMode])
|
||||
const prevSearchKey = usePrevious(searchKey)
|
||||
React.useEffect(() => {
|
||||
// when start searching or stop searching
|
||||
if (!prevSearchKey !== !searchKey) scrollToItem(0, alignMode)
|
||||
}, [prevSearchKey, searchKey, scrollToItem, alignMode])
|
||||
|
||||
const getCurrentPath = useGetCurrentPath(metaData)
|
||||
const methods = useVisibleNodesGeneratorMethods(
|
||||
visibleNodesGenerator,
|
||||
getCurrentPath,
|
||||
updateSearchKey,
|
||||
)
|
||||
const { expandTo, goTo, focusNode } = methods
|
||||
const handleNodeFocus = useHandleNodeFocus(methods, setAlignMode)
|
||||
const handleNodeClick = useHandleNodeClick(methods, setAlignMode)
|
||||
const handleKeyDown = useHandleKeyDown(visibleNodes, methods, searched, setAlignMode)
|
||||
const handleFocusSearchBar = () => focusNode(null)
|
||||
|
||||
const renderActions = useNodeRenderers([
|
||||
useRenderGoToButton(searched, goTo),
|
||||
useRenderFindInFolderButton(onSearch),
|
||||
useRenderFileCommentAmounts(),
|
||||
useRenderFileStatus(),
|
||||
])
|
||||
const renderLabelText = useRenderLabelText(searchKey)
|
||||
|
||||
useFocusFileExplorerOnFirstRender()
|
||||
|
||||
const goToCurrentItem = React.useCallback(() => {
|
||||
const targetPath = platform.getCurrentPath(metaData.branchName)
|
||||
if (targetPath) expandTo(targetPath)
|
||||
}, [metaData.branchName, expandTo])
|
||||
|
||||
useOnLocationChange(goToCurrentItem)
|
||||
useAfterRedirect(goToCurrentItem)
|
||||
|
||||
return (
|
||||
<div className={`file-explorer`} tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
{visibleNodesGenerator?.defer && (
|
||||
<div className={'status'}>
|
||||
<Label
|
||||
title="This repository is large. Gitako has switched to Lazy Mode to improve performance. Folders will be loaded on demand."
|
||||
className={'lazy-mode'}
|
||||
variant="attention"
|
||||
>
|
||||
Lazy Mode is ON
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<SearchBar value={searchKey} onSearch={onSearch} onFocus={handleFocusSearchBar} />
|
||||
{searched && visibleNodes.nodes.length === 0 && (
|
||||
<>
|
||||
<Text marginTop={6} textAlign="center" color="text.gray">
|
||||
No results found.
|
||||
</Text>
|
||||
{visibleNodesGenerator?.defer && (
|
||||
<Text textAlign="center" color="gray.4" fontSize="12px">
|
||||
Search results are limited to loaded folders in Lazy Mode.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cx('files', {
|
||||
// instead of unmounting, hide the element when not needed, so that the ref can be preserved after search result matches nothing
|
||||
hidden: visibleNodes.nodes.length === 0,
|
||||
})}
|
||||
tabIndex={-1} // prevent getting focus via tab key on GitHub
|
||||
ref={filesRef}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
position: 'absolute', // This allows reducing `height` on viewport height reduce
|
||||
}}
|
||||
ref={scrollElementRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<div style={containerStyle}>
|
||||
{visibleRows.map(({ row, style }) => {
|
||||
const node = nodes[row]
|
||||
return (
|
||||
<Node
|
||||
key={node.path}
|
||||
node={node}
|
||||
style={style}
|
||||
depth={depths.get(node) || 0}
|
||||
focused={focusedNode?.path === node.path}
|
||||
loading={loading.has(node.path)}
|
||||
expanded={expandedNodes.has(node.path)}
|
||||
onClick={handleNodeClick}
|
||||
onFocus={handleNodeFocus}
|
||||
renderLabelText={renderLabelText}
|
||||
renderActions={renderActions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/components/FileExplorer/useHandleNodeFocus.tsx
Normal file
16
src/components/FileExplorer/useHandleNodeFocus.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import { VisibleNodesGeneratorMethods } from './hooks/useVisibleNodesGeneratorMethods'
|
||||
import { AlignMode } from './useVirtualScroll'
|
||||
|
||||
export function useHandleNodeFocus(
|
||||
{ focusNode }: VisibleNodesGeneratorMethods,
|
||||
setAlignMode: (mode: AlignMode) => void,
|
||||
) {
|
||||
return React.useCallback(
|
||||
(event: React.FocusEvent<HTMLElement, Element>, node: TreeNode) => {
|
||||
setAlignMode('lazy')
|
||||
focusNode(node)
|
||||
},
|
||||
[focusNode, setAlignMode],
|
||||
)
|
||||
}
|
||||
15
src/components/FileExplorer/useLatestValueRef.tsx
Normal file
15
src/components/FileExplorer/useLatestValueRef.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import * as React from 'react'
|
||||
|
||||
function useLatestValueRef<T>(value: T) {
|
||||
const ref = React.useRef(value)
|
||||
React.useEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
return ref
|
||||
}
|
||||
export function useCallbackRef<Args extends AnyArray, R>(
|
||||
callback: (...args: Args) => R,
|
||||
): (...args: Args) => R {
|
||||
const ref = useLatestValueRef(callback)
|
||||
return React.useCallback((...args: Args) => ref.current(...args), [ref])
|
||||
}
|
||||
122
src/components/FileExplorer/useVirtualScroll.tsx
Normal file
122
src/components/FileExplorer/useVirtualScroll.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import * as React from 'react'
|
||||
import { useCallbackRef } from './useLatestValueRef'
|
||||
|
||||
function memoize<Args extends AnyArray, R>(
|
||||
fn: (...args: Args) => R,
|
||||
serializeArguments: (...args: Args) => string | number,
|
||||
): (...args: Args) => R {
|
||||
const memory = new Map<string | number, R>()
|
||||
return (...args) => {
|
||||
const key = serializeArguments(...args)
|
||||
let r = memory.get(key)
|
||||
if (!r) memory.set(key, (r = fn(...args)))
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
export type AlignMode = 'top' | 'end' | 'lazy'
|
||||
|
||||
export function useVirtualScroll<E extends HTMLElement>({
|
||||
totalAmount,
|
||||
viewportHeight,
|
||||
rowHeight,
|
||||
overScan = 0,
|
||||
}: {
|
||||
totalAmount: number
|
||||
overScan?: number
|
||||
viewportHeight: number
|
||||
rowHeight: number
|
||||
}) {
|
||||
const totalHeight = totalAmount * rowHeight
|
||||
|
||||
const ref = React.useRef<E | null>(null) // TODO: compare DOM native event listener
|
||||
const [scrollTop, setScrollTop] = React.useState(0)
|
||||
|
||||
const onScroll = React.useCallback((e: React.UIEvent<E, UIEvent>) => {
|
||||
setScrollTop(e.currentTarget.scrollTop)
|
||||
}, [])
|
||||
|
||||
const [startRenderIndex, endRenderIndex] = React.useMemo(() => {
|
||||
const viewportLastItemOverflow = viewportHeight % rowHeight
|
||||
const visibleRowCount = (viewportHeight - viewportLastItemOverflow) / rowHeight
|
||||
const inViewIndexFirst = (Math.min(scrollTop, totalHeight - viewportHeight) / rowHeight) >> 0
|
||||
const inViewIndexLast = inViewIndexFirst + visibleRowCount
|
||||
const renderIndexFirst = Math.max(0, inViewIndexFirst - overScan)
|
||||
const renderIndexLast = Math.min(totalAmount, inViewIndexLast + overScan)
|
||||
return [renderIndexFirst, renderIndexLast]
|
||||
}, [scrollTop, viewportHeight, overScan, rowHeight, totalAmount, totalHeight])
|
||||
|
||||
const indexes = React.useMemo(() => {
|
||||
const indexes: number[] = []
|
||||
let i = startRenderIndex
|
||||
while (i < endRenderIndex) indexes.push(i++)
|
||||
return indexes
|
||||
}, [startRenderIndex, endRenderIndex])
|
||||
|
||||
const mapStyles = React.useCallback(
|
||||
(row: number): React.CSSProperties => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${row * rowHeight}px)`,
|
||||
width: '100%',
|
||||
height: rowHeight,
|
||||
}),
|
||||
[rowHeight],
|
||||
)
|
||||
const memoizedStyler = React.useMemo(() => memoize(mapStyles, row => row), [mapStyles])
|
||||
|
||||
const visibleRows: { row: number; style: React.CSSProperties }[] = React.useMemo(
|
||||
() =>
|
||||
indexes.map(row => ({
|
||||
row,
|
||||
style: memoizedStyler(row),
|
||||
})),
|
||||
[indexes, memoizedStyler],
|
||||
)
|
||||
|
||||
const containerStyle: React.CSSProperties = React.useMemo(
|
||||
() => ({
|
||||
height: totalHeight,
|
||||
position: 'relative',
|
||||
}),
|
||||
[totalHeight],
|
||||
)
|
||||
|
||||
const scrollToItem = useCallbackRef((row: number, mode: AlignMode) => {
|
||||
const getOffsetEnd = () => row * rowHeight + rowHeight - viewportHeight
|
||||
const getOffsetTop = () => row * rowHeight
|
||||
|
||||
const updateScrollPosition = (scrollTop: number) => {
|
||||
setScrollTop(scrollTop)
|
||||
|
||||
// Note: storing the scrollTop into a state and update DOM element scrollTop inside a layout effect would not work.
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'top':
|
||||
return updateScrollPosition(getOffsetTop())
|
||||
case 'end':
|
||||
return updateScrollPosition(getOffsetEnd())
|
||||
case 'lazy': {
|
||||
const isAbove = row * rowHeight < scrollTop
|
||||
const isBelow = row * rowHeight + rowHeight > scrollTop + viewportHeight
|
||||
if (isBelow) {
|
||||
updateScrollPosition(getOffsetEnd())
|
||||
} else if (isAbove) {
|
||||
updateScrollPosition(getOffsetTop())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ref,
|
||||
visibleRows,
|
||||
onScroll,
|
||||
containerStyle,
|
||||
scrollToItem,
|
||||
}
|
||||
}
|
||||
15
src/components/FileExplorer/useVisibleNodes.tsx
Normal file
15
src/components/FileExplorer/useVisibleNodes.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
|
||||
export function useVisibleNodes(visibleNodesGenerator: VisibleNodesGenerator | null) {
|
||||
const [visibleNodes, setVisibleNodes] = useState<VisibleNodes | null>(
|
||||
visibleNodesGenerator?.visibleNodes || null,
|
||||
)
|
||||
useEffect(() => {
|
||||
const $visibleNodes = visibleNodesGenerator?.visibleNodes || null
|
||||
if (visibleNodes !== $visibleNodes) setVisibleNodes($visibleNodes)
|
||||
|
||||
return visibleNodesGenerator?.onUpdate(setVisibleNodes)
|
||||
}, [visibleNodesGenerator]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return visibleNodes
|
||||
}
|
||||
33
src/components/Footer.tsx
Normal file
33
src/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { GearIcon } from '@primer/octicons-react'
|
||||
import { Link } from '@primer/react'
|
||||
import { VERSION } from 'env'
|
||||
import * as React from 'react'
|
||||
import { RoundIconButton } from './RoundIconButton'
|
||||
import { wikiLinks } from './settings/SettingsBar'
|
||||
|
||||
type Props = {
|
||||
toggleShowSettings: () => void
|
||||
}
|
||||
|
||||
export function Footer(props: Props) {
|
||||
const { toggleShowSettings } = props
|
||||
return (
|
||||
<div className={'gitako-footer'}>
|
||||
<Link
|
||||
className={'version'}
|
||||
href={wikiLinks.changeLog}
|
||||
title={'Check out new features!'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{VERSION}
|
||||
</Link>
|
||||
<RoundIconButton
|
||||
aria-label={'settings'}
|
||||
icon={GearIcon}
|
||||
iconColor="fg.muted"
|
||||
onClick={toggleShowSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { SideBar } from 'components/SideBar'
|
||||
import { ConfigsContextWrapper } from 'containers/ConfigsContext'
|
||||
import { ReloadContextWrapper } from 'containers/ReloadContext'
|
||||
import { InspectorContextWrapper } from 'containers/StateInspector'
|
||||
import * as React from 'react'
|
||||
import { StyleSheetManager } from 'styled-components'
|
||||
import { insertMountPoint } from 'utils/DOMHelper'
|
||||
import { ErrorBoundary } from '../containers/ErrorBoundary'
|
||||
import { StateBarErrorContextWrapper } from '../containers/ErrorContext'
|
||||
import { OAuthWrapper } from '../containers/OAuthWrapper'
|
||||
|
|
@ -9,18 +13,24 @@ import { StateBarStateContextWrapper } from '../containers/SideBarState'
|
|||
|
||||
export function Gitako() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ConfigsContextWrapper>
|
||||
<StateBarStateContextWrapper>
|
||||
<StateBarErrorContextWrapper>
|
||||
<OAuthWrapper>
|
||||
<RepoContextWrapper>
|
||||
<SideBar />
|
||||
</RepoContextWrapper>
|
||||
</OAuthWrapper>
|
||||
</StateBarErrorContextWrapper>
|
||||
</StateBarStateContextWrapper>
|
||||
</ConfigsContextWrapper>
|
||||
</ErrorBoundary>
|
||||
<InspectorContextWrapper>
|
||||
<StyleSheetManager target={insertMountPoint()}>
|
||||
<ReloadContextWrapper>
|
||||
<ErrorBoundary>
|
||||
<ConfigsContextWrapper>
|
||||
<StateBarStateContextWrapper>
|
||||
<StateBarErrorContextWrapper>
|
||||
<OAuthWrapper>
|
||||
<RepoContextWrapper>
|
||||
<SideBar />
|
||||
</RepoContextWrapper>
|
||||
</OAuthWrapper>
|
||||
</StateBarErrorContextWrapper>
|
||||
</StateBarStateContextWrapper>
|
||||
</ConfigsContextWrapper>
|
||||
</ErrorBoundary>
|
||||
</ReloadContextWrapper>
|
||||
</StyleSheetManager>
|
||||
</InspectorContextWrapper>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
15
src/components/Highlight.test.tsx
Normal file
15
src/components/Highlight.test.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import React, { ComponentProps } from 'react'
|
||||
import { Highlight } from './Highlight'
|
||||
|
||||
function test(title: string, text: string, match?: ComponentProps<typeof Highlight>['match']) {
|
||||
it(title, () => {
|
||||
expect(render(<Highlight text={text} match={match} />).container.textContent).toBe(text)
|
||||
})
|
||||
}
|
||||
|
||||
test('sample', 'abc', undefined)
|
||||
test('sample', 'abc', /./)
|
||||
test('sample', 'abc', /../)
|
||||
test('sample', 'abc', /.../)
|
||||
test('sample', 'abc', /..../)
|
||||
|
|
@ -1,35 +1,41 @@
|
|||
import * as React from 'react'
|
||||
|
||||
export function Highlight(props: { text: string; match?: RegExp | string }) {
|
||||
export const Highlight = React.memo(function Highlight(props: { text: string; match?: RegExp }) {
|
||||
const { text, match } = props
|
||||
const $match = React.useMemo(() => {
|
||||
if (match) {
|
||||
if (match instanceof RegExp) {
|
||||
if (match.flags.includes('g')) return match
|
||||
return new RegExp(match.source, 'g' + match.flags)
|
||||
}
|
||||
return new RegExp(match, 'g')
|
||||
if (match instanceof RegExp) {
|
||||
if (match.flags.includes('g')) return match
|
||||
return new RegExp(match.source, 'g' + match.flags)
|
||||
}
|
||||
return null
|
||||
}, [match])
|
||||
|
||||
if (!$match) return <>{text}</>
|
||||
const chunks = useChunks(text, $match)
|
||||
|
||||
return <>{chunks.map(([type, text], key) => React.createElement(type, { key }, text))}</>
|
||||
})
|
||||
|
||||
function useChunks(text: string, $match: RegExp | null) {
|
||||
const contents: [string, string][] = []
|
||||
if ($match === null) return [['span', text]]
|
||||
|
||||
const matchedPieces = Array.from(text.matchAll($match)).map(
|
||||
([text, highlightText = text]) => highlightText,
|
||||
)
|
||||
const preservedPieces = text.split($match)
|
||||
const content = []
|
||||
|
||||
let i = 0
|
||||
while (matchedPieces.length || preservedPieces.length) {
|
||||
if (preservedPieces.length) {
|
||||
content.push(<span key={i++}>{preservedPieces.shift()}</span>)
|
||||
}
|
||||
if (matchedPieces.length) {
|
||||
content.push(<mark key={i++}>{matchedPieces.shift()}</mark>)
|
||||
}
|
||||
const push = (type: string, text: string) => {
|
||||
if (!text) return
|
||||
const last = contents[contents.length - 1]
|
||||
if (last && last[0] === type) last[1] += text
|
||||
else contents.push([type, text])
|
||||
}
|
||||
|
||||
return <>{content}</>
|
||||
let i = 0
|
||||
while (i < Math.max(matchedPieces.length, preservedPieces.length)) {
|
||||
push('span', preservedPieces[i])
|
||||
push('mark', matchedPieces[i])
|
||||
++i
|
||||
}
|
||||
return contents
|
||||
}
|
||||
|
|
|
|||
27
src/components/HighlightOnIndexes.test.tsx
Normal file
27
src/components/HighlightOnIndexes.test.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { is } from 'utils/is'
|
||||
import { HighlightOnIndexes } from './HighlightOnIndexes'
|
||||
|
||||
function test(title: string, text: string, indexes?: number[]) {
|
||||
it(title, () => {
|
||||
expect(render(<HighlightOnIndexes text={text} indexes={indexes} />).container.textContent).toBe(
|
||||
text,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const text = 'abcdef'
|
||||
for (let i = 0; i < Math.pow(2, text.length); i++) {
|
||||
const bitwise = i.toString(2)
|
||||
const indexes = bitwise
|
||||
.split('')
|
||||
.reverse()
|
||||
.map((bit, index) => (bit === '1' ? index : false))
|
||||
.filter(is.not.false)
|
||||
test(
|
||||
`renders properly when highlight ${bitwise.padStart(text.length, '0')}, indexes [${indexes}]`,
|
||||
text,
|
||||
indexes,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +1,28 @@
|
|||
import * as React from 'react'
|
||||
|
||||
export function HighlightOnIndexes(props: { text: string; indexes?: number[] }) {
|
||||
const { text, indexes } = props
|
||||
|
||||
if (!indexes?.length) return <>{text}</>
|
||||
|
||||
export function HighlightOnIndexes({ text, indexes = [] }: { text: string; indexes?: number[] }) {
|
||||
return (
|
||||
<>
|
||||
{text
|
||||
.split('')
|
||||
.map((char, i) =>
|
||||
indexes.includes(i) ? <mark key={i}>{char}</mark> : <span key={i}>{char}</span>,
|
||||
)}
|
||||
{[-1]
|
||||
.concat(indexes)
|
||||
.map((index, i, arr) => [
|
||||
index === -1 ? '' : text.slice(index, index + 1),
|
||||
text.slice(index + 1, arr[i + 1]),
|
||||
])
|
||||
.reduce((arr, pair) => {
|
||||
const last = arr[arr.length - 1]
|
||||
if (last && !last[1]) {
|
||||
last[0] += pair[0]
|
||||
last[1] += pair[1]
|
||||
} else {
|
||||
arr.push(pair)
|
||||
}
|
||||
return arr
|
||||
}, [] as string[][])
|
||||
.map(([chunk, nextChunk], i) => [
|
||||
chunk && <mark key={i * 2}>{chunk}</mark>,
|
||||
nextChunk && <span key={i * 2 + 1}>{nextChunk}</span>,
|
||||
])}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
import * as React from 'react'
|
||||
|
||||
// I tried to install `react-iifc` but that causes TS build errors for unknown reason
|
||||
// So here the duplicated code is
|
||||
|
||||
export function IIFC({ children }: { children(): React.ReactNode }) {
|
||||
return <>{children()}</>
|
||||
}
|
||||
|
|
@ -19,12 +19,11 @@ import {
|
|||
HourglassIcon as Hourglass,
|
||||
IconProps,
|
||||
MarkdownIcon as Markdown,
|
||||
OctofaceIcon as Octoface,
|
||||
PinIcon as Pin,
|
||||
ReplyIcon as Reply,
|
||||
SearchIcon as Search,
|
||||
TabIcon as Tab,
|
||||
XIcon as X
|
||||
XIcon as X,
|
||||
} from '@primer/octicons-react'
|
||||
import * as React from 'react'
|
||||
import { cx } from 'utils/cx'
|
||||
|
|
@ -42,7 +41,6 @@ const iconToComponentMap = {
|
|||
Hourglass,
|
||||
Submodule,
|
||||
Grabber,
|
||||
Octoface,
|
||||
ChevronDown,
|
||||
X,
|
||||
Gear,
|
||||
|
|
@ -63,15 +61,7 @@ const typeToIconComponentMap: {
|
|||
} = {
|
||||
search: 'Search',
|
||||
loading: 'Clock',
|
||||
hourglass: 'Hourglass',
|
||||
submodule: 'Submodule',
|
||||
grabber: 'Grabber',
|
||||
octoface: 'Octoface',
|
||||
comment: 'Comment',
|
||||
x: 'X',
|
||||
pin: 'Pin',
|
||||
tab: 'Tab',
|
||||
gear: 'Gear',
|
||||
diff: 'Diff',
|
||||
diffAdded: 'DiffAdded',
|
||||
diffIgnored: 'DiffIgnored',
|
||||
|
|
@ -79,7 +69,6 @@ const typeToIconComponentMap: {
|
|||
diffRemoved: 'DiffRemoved',
|
||||
diffRenamed: 'DiffRenamed',
|
||||
folder: 'ChevronRight',
|
||||
'chevron-down': 'ChevronDown',
|
||||
'go-to': 'Reply',
|
||||
'.zip': 'FileZip',
|
||||
'.rar': 'FileZip',
|
||||
|
|
@ -102,7 +91,9 @@ const typeToIconComponentMap: {
|
|||
}
|
||||
|
||||
type Props = {
|
||||
type: string
|
||||
type?: keyof typeof typeToIconComponentMap
|
||||
name?: keyof typeof iconToComponentMap
|
||||
IconComponent?: React.ComponentType<IconProps>
|
||||
className?: string
|
||||
placeholder?: boolean
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void
|
||||
|
|
@ -110,19 +101,15 @@ type Props = {
|
|||
|
||||
export const Icon = React.memo(function Icon({
|
||||
type,
|
||||
className = undefined,
|
||||
placeholder,
|
||||
className = undefined,
|
||||
name = (type && typeToIconComponentMap[type]) || defaultIcon,
|
||||
IconComponent = iconToComponentMap[name],
|
||||
...otherProps
|
||||
}: Props) {
|
||||
let children: React.ReactNode = null
|
||||
if (!placeholder) {
|
||||
const name = typeToIconComponentMap[type] || defaultIcon
|
||||
const IconComponent = iconToComponentMap[name]
|
||||
children = <IconComponent className={cx('octicon', name)} {...otherProps} />
|
||||
}
|
||||
return (
|
||||
<div className={cx('octicon-wrapper', className)} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
<span className={cx('octicon-wrapper', className)} {...otherProps}>
|
||||
{placeholder ? null : <IconComponent className={cx('octicon', name)} {...otherProps} />}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
49
src/components/IconButton.tsx
Normal file
49
src/components/IconButton.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { IconProps } from '@primer/octicons-react'
|
||||
import { Box, merge, SxProp, useTheme } from '@primer/react'
|
||||
import { getBaseStyles, getSizeStyles, getVariantStyles } from '@primer/react/lib/Button/styles'
|
||||
import {
|
||||
IconButtonProps as PrimerIconButtonProps,
|
||||
StyledButton,
|
||||
} from '@primer/react/lib/Button/types'
|
||||
import React from 'react'
|
||||
import { is } from 'utils/is'
|
||||
|
||||
export type IconButtonProps = PrimerIconButtonProps & {
|
||||
iconSize?: IconProps['size']
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
// Modified version of @primer/react/lib/Button/Button.tsx
|
||||
// Added better support of colors & size
|
||||
|
||||
export function IconButton(props: IconButtonProps) {
|
||||
const {
|
||||
variant = 'default',
|
||||
size = 'medium',
|
||||
iconSize, // grow the icon to the same size as the button
|
||||
iconColor, // extra control of icon color
|
||||
sx: sxProp = {},
|
||||
icon: Icon,
|
||||
...rest
|
||||
} = props
|
||||
const { theme } = useTheme()
|
||||
const sxStyles = merge.all(
|
||||
[
|
||||
getBaseStyles(theme),
|
||||
getSizeStyles(size, variant, true),
|
||||
getVariantStyles(variant, theme),
|
||||
// Unsatisfied with preset color of the `invisible` variant
|
||||
{
|
||||
color: iconColor || (variant === 'invisible' ? 'fg.subtle' : undefined),
|
||||
},
|
||||
sxProp as SxProp,
|
||||
].filter(is.not.undefined),
|
||||
)
|
||||
return (
|
||||
<StyledButton sx={sxStyles} {...rest}>
|
||||
<Box as="span" sx={{ display: 'inline-block' }}>
|
||||
<Icon size={iconSize} />
|
||||
</Box>
|
||||
</StyledButton>
|
||||
)
|
||||
}
|
||||
24
src/components/Inputs/Checkbox.tsx
Normal file
24
src/components/Inputs/Checkbox.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Checkbox as PrimerCheckbox, CheckboxProps, FormControl } from '@primer/react'
|
||||
import * as React from 'react'
|
||||
|
||||
export function Checkbox({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
checked = value,
|
||||
...rest
|
||||
}: Override<CheckboxProps, { label: React.ReactNode } & IO<boolean>>) {
|
||||
return (
|
||||
<FormControl>
|
||||
<PrimerCheckbox
|
||||
sx={{
|
||||
marginTop: '4px', // align label
|
||||
}}
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
{...rest}
|
||||
/>
|
||||
<FormControl.Label>{label}</FormControl.Label>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
61
src/components/Inputs/SelectInput.tsx
Normal file
61
src/components/Inputs/SelectInput.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { FormControl, Select, SelectProps } from '@primer/react'
|
||||
import * as React from 'react'
|
||||
|
||||
export type Option<T> = {
|
||||
key: string
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
||||
export type SelectInputProps<T> = Override<
|
||||
SelectProps,
|
||||
IO<T> & {
|
||||
label: React.ReactNode
|
||||
options: Option<T>[]
|
||||
}
|
||||
>
|
||||
|
||||
export function SelectInput<T>({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
options,
|
||||
...selectProps
|
||||
}: SelectInputProps<T>) {
|
||||
return (
|
||||
<FormControl
|
||||
sx={{
|
||||
':focus-within': {
|
||||
'> span': {
|
||||
// original boxShadow does not look right
|
||||
borderWidth: '2px',
|
||||
boxShadow: 'none',
|
||||
'> select': {
|
||||
paddingLeft: '11px',
|
||||
paddingRight: '11px',
|
||||
},
|
||||
},
|
||||
},
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<FormControl.Label>{label}</FormControl.Label>
|
||||
<Select
|
||||
block
|
||||
onChange={e => {
|
||||
const key = e.target.value
|
||||
const option = options.find(option => option.key === key)
|
||||
if (option) onChange(option.value)
|
||||
}}
|
||||
value={options.find(option => option.value === value)?.key}
|
||||
{...selectProps}
|
||||
>
|
||||
{options.map(option => (
|
||||
<Select.Option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Icon } from 'components/Icon'
|
||||
import { HourglassIcon } from '@primer/octicons-react'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
|
|
@ -8,7 +8,7 @@ export function LoadingIndicator({ text }: Props) {
|
|||
return (
|
||||
<div className={'loading-indicator-container'}>
|
||||
<div className={'loading-indicator'}>
|
||||
<Icon className={'loading-indicator-icon'} type={'hourglass'} />
|
||||
<HourglassIcon className={'loading-indicator-icon'} />
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,47 @@
|
|||
import { BranchName, Breadcrumb, Flex, Text } from '@primer/components'
|
||||
import { GitBranchIcon } from '@primer/octicons-react'
|
||||
import { Box, BranchName, Breadcrumbs, Text } from '@primer/react'
|
||||
import { RepoContext } from 'containers/RepoContext'
|
||||
import { platform } from 'platforms'
|
||||
import * as React from 'react'
|
||||
import { createAnchorClickHandler } from "utils/createAnchorClickHandler"
|
||||
import { createAnchorClickHandler } from 'utils/createAnchorClickHandler'
|
||||
|
||||
type Props = {
|
||||
metaData: MetaData
|
||||
}
|
||||
export function MetaBar() {
|
||||
const metaData = React.useContext(RepoContext)
|
||||
if (!metaData) return null
|
||||
|
||||
export function MetaBar({ metaData }: Props) {
|
||||
const { userName, repoName, branchName } = metaData
|
||||
const { repoUrl, userUrl, branchUrl } = platform.resolveUrlFromMetaData(metaData)
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb className={'user-and-repo'}>
|
||||
<Breadcrumb.Item className={'user-name'} href={userUrl}>
|
||||
<Breadcrumbs className={'user-and-repo'}>
|
||||
<Breadcrumbs.Item className={'user-name'} href={userUrl}>
|
||||
{userName}
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Item
|
||||
</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Item
|
||||
className={'repo-name'}
|
||||
href={repoUrl}
|
||||
onClick={createAnchorClickHandler(repoUrl)}
|
||||
{...platform.delegatePJAXProps?.()}
|
||||
{...platform.delegateFastRedirectAnchorProps?.()}
|
||||
>
|
||||
<Text fontWeight="bolder">{repoName}</Text>
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
<Flex paddingTop={1} flexWrap="nowrap" alignItems="flex-start">
|
||||
</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
<Box display="flex" paddingTop={1} flexWrap="nowrap" alignItems="flex-start">
|
||||
<div className={'octicon-wrapper'}>
|
||||
<GitBranchIcon size="small" />
|
||||
</div>
|
||||
<BranchName
|
||||
href={branchUrl}
|
||||
as="a"
|
||||
className={'branch-name'}
|
||||
onClick={createAnchorClickHandler(branchUrl)}
|
||||
{...platform.delegatePJAXProps?.()}
|
||||
sx={{
|
||||
color: 'fg.muted',
|
||||
}}
|
||||
{...platform.delegateFastRedirectAnchorProps?.()}
|
||||
>
|
||||
{branchName || '...'}
|
||||
</BranchName>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { GrabberIcon } from '@primer/octicons-react'
|
||||
import { Icon } from 'components/Icon'
|
||||
import * as React from 'react'
|
||||
import { ResizeState, useResizeHandler } from '../utils/hooks/useResizeHandler'
|
||||
import { Size2D } from './SideBarBodyWrapper'
|
||||
import { Size2D } from './Size'
|
||||
|
||||
type Props = {
|
||||
size: Size2D
|
||||
|
|
@ -21,7 +22,7 @@ export function ResizeHandler({ onResize, onResetSize, onResizeStateChange, size
|
|||
onDoubleClick={onResetSize}
|
||||
style={style}
|
||||
>
|
||||
<Icon type={'grabber'} className={'grabber-icon'} size={20} />
|
||||
<Icon IconComponent={GrabberIcon} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
16
src/components/RoundIconButton.tsx
Normal file
16
src/components/RoundIconButton.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import { IconButton, IconButtonProps } from './IconButton'
|
||||
|
||||
export function RoundIconButton(props: IconButtonProps) {
|
||||
return (
|
||||
<IconButton
|
||||
variant="invisible"
|
||||
title={props['aria-label']}
|
||||
{...props}
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
...props.sx,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { TextInput, TextInputProps } from '@primer/components'
|
||||
import { SearchIcon } from '@primer/octicons-react'
|
||||
import { TextInput, TextInputProps } from '@primer/react'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { cx } from 'utils/cx'
|
||||
import { isValidRegexpSource } from 'utils/general'
|
||||
import { SearchMode } from './searchModes'
|
||||
|
||||
|
|
@ -15,34 +14,35 @@ export function SearchBar({ onSearch, onFocus, value }: Props) {
|
|||
const configs = useConfigs()
|
||||
const { searchMode } = configs.value
|
||||
|
||||
const toggleButtonDescription = `${
|
||||
const toggleButtonDescription =
|
||||
searchMode === 'regex'
|
||||
? 'Match file name with regular expression.'
|
||||
: 'Match file path sequence with input.'
|
||||
} Click to toggle.`
|
||||
: `Match file path sequence with plain input.`
|
||||
|
||||
const validationStatus = React.useMemo(
|
||||
() => (searchMode === 'regex' && !isValidRegexpSource(value) ? 'error' : undefined),
|
||||
[value, searchMode],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={'search-input-wrapper'}>
|
||||
<TextInput
|
||||
backgroundColor="white"
|
||||
icon={SearchIcon as any}
|
||||
onFocus={e => {
|
||||
onFocus(e)
|
||||
e.target.select()
|
||||
}}
|
||||
tabIndex={0}
|
||||
className={cx('search-input', {
|
||||
error: searchMode === 'regex' && !isValidRegexpSource(value),
|
||||
})}
|
||||
aria-label="search files"
|
||||
placeholder={`Search files`}
|
||||
onChange={({ target: { value } }) => onSearch(value, searchMode)}
|
||||
value={value}
|
||||
/>
|
||||
<div className={`actions`}>
|
||||
<button
|
||||
className={`toggle-search-mode`}
|
||||
title={toggleButtonDescription}
|
||||
<TextInput
|
||||
leadingVisual={SearchIcon}
|
||||
onFocus={e => {
|
||||
onFocus(e)
|
||||
e.target.select()
|
||||
}}
|
||||
block
|
||||
sx={{ borderRadius: 0 }}
|
||||
className={'search-input'}
|
||||
aria-label="search files"
|
||||
placeholder={`Search files`}
|
||||
onChange={({ target: { value } }) => onSearch(value, searchMode)}
|
||||
value={value}
|
||||
validationStatus={validationStatus}
|
||||
trailingAction={
|
||||
<TextInput.Action
|
||||
aria-label={toggleButtonDescription}
|
||||
sx={{ color: 'fg.subtle' }}
|
||||
onClick={() => {
|
||||
const newMode = searchMode === 'regex' ? 'fuzzy' : 'regex'
|
||||
configs.onChange({
|
||||
|
|
@ -51,11 +51,10 @@ export function SearchBar({ onSearch, onFocus, value }: Props) {
|
|||
// Skip search if no input to prevent resetting folder expansions
|
||||
if (value) onSearch(value, newMode)
|
||||
}}
|
||||
aria-label={toggleButtonDescription}
|
||||
>
|
||||
{searchMode === 'regex' ? '.*' : 'path'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{searchMode === 'regex' ? '.*$' : 'a/b'}
|
||||
</TextInput.Action>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import * as React from 'react'
|
||||
export function SelectInput<T>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
...selectProps
|
||||
}: Override<
|
||||
React.DetailedHTMLProps<React.SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement>,
|
||||
IO<T> & {
|
||||
options: Option<T>[]
|
||||
}
|
||||
>) {
|
||||
return (
|
||||
<div className={'select-wrapper'}>
|
||||
<select
|
||||
onChange={e => {
|
||||
const key = e.target.value
|
||||
const option = options.find(option => option.key === key)
|
||||
onChange(option!?.value)
|
||||
}}
|
||||
value={options.find(option => option.value === value)?.key}
|
||||
{...selectProps}
|
||||
>
|
||||
{options.map(option => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className={'chevron'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export type Option<T> = {
|
||||
key: string
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
|
@ -1,220 +1,278 @@
|
|||
import { PinIcon, TabIcon } from '@primer/octicons-react'
|
||||
import { AccessDeniedDescription } from 'components/AccessDeniedDescription'
|
||||
import { FileExplorer } from 'components/FileExplorer'
|
||||
import { Footer } from 'components/Footer'
|
||||
import { MetaBar } from 'components/MetaBar'
|
||||
import { Portal } from 'components/Portal'
|
||||
import { SettingsBar } from 'components/settings/SettingsBar'
|
||||
import { SideBarBodyWrapper } from 'components/SideBarBodyWrapper'
|
||||
import { ToggleShowButton } from 'components/ToggleShowButton'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { platform } from 'platforms'
|
||||
import * as React from 'react'
|
||||
import { IIFC } from 'react-iifc'
|
||||
import { useWindowSize } from 'react-use'
|
||||
import { Config } from 'utils/config/helper'
|
||||
import { cx } from 'utils/cx'
|
||||
import * as DOMHelper from 'utils/DOMHelper'
|
||||
import { detectBrowser, run } from 'utils/general'
|
||||
import { useCatchNetworkError } from 'utils/hooks/useCatchNetworkError'
|
||||
import * as features from 'utils/features'
|
||||
import { detectBrowser } from 'utils/general'
|
||||
import { useConditionalHook } from 'utils/hooks/useConditionalHook'
|
||||
import { useAfterRedirect, usePJAXAPI } from 'utils/hooks/useFastRedirect'
|
||||
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
|
||||
import { useOnPJAXDone, usePJAX } from 'utils/hooks/usePJAX'
|
||||
import { useStateIO } from 'utils/hooks/useStateIO'
|
||||
import { ResizeState } from 'utils/hooks/useResizeHandler'
|
||||
import * as keyHelper from 'utils/keyHelper'
|
||||
import { SideBarErrorContext } from '../containers/ErrorContext'
|
||||
import { RepoContext } from '../containers/RepoContext'
|
||||
import { SideBarStateContext } from '../containers/SideBarState'
|
||||
import { Theme } from '../containers/Theme'
|
||||
import { useToggleSideBarWithKeyboard } from '../utils/hooks/useToggleSideBarWithKeyboard'
|
||||
import { Icon } from './Icon'
|
||||
import { IIFC } from './IIFC'
|
||||
import { LoadingIndicator } from './LoadingIndicator'
|
||||
import { RoundIconButton } from './RoundIconButton'
|
||||
import { SettingsBarContent } from './settings/SettingsBar'
|
||||
import { SideBarResizeHandler } from './SideBarResizeHandler'
|
||||
|
||||
export function SideBar() {
|
||||
const metaData = React.useContext(RepoContext)
|
||||
const state = useLoadedContext(SideBarStateContext).value
|
||||
const configContext = useConfigs()
|
||||
|
||||
const accessToken = configContext.value.accessToken || ''
|
||||
const [baseSize] = React.useState(() => configContext.value.sideBarWidth)
|
||||
|
||||
const $showSettings = useStateIO(false)
|
||||
const showSettings = $showSettings.value
|
||||
const toggleShowSettings = React.useCallback(() => $showSettings.onChange(show => !show), [])
|
||||
|
||||
const $logoContainerElement = useStateIO<HTMLElement | null>(null)
|
||||
|
||||
const hasMetaData = state !== 'disabled' // will be true since retrieving data, cannot use Boolean(metaData)
|
||||
React.useEffect(() => {
|
||||
if (hasMetaData) {
|
||||
DOMHelper.markGitakoReadyState(true)
|
||||
$showSettings.onChange(false)
|
||||
$logoContainerElement.onChange(DOMHelper.insertLogoMountPoint())
|
||||
} else {
|
||||
DOMHelper.markGitakoReadyState(false)
|
||||
}
|
||||
}, [hasMetaData])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (detectBrowser() === 'Safari') DOMHelper.markGitakoSafariFlag()
|
||||
}, [])
|
||||
|
||||
const sidebarToggleMode = configContext.value.sidebarToggleMode
|
||||
const intelligentToggle = configContext.value.intelligentToggle
|
||||
const $shouldShow = useStateIO(() =>
|
||||
intelligentToggle === null
|
||||
? sidebarToggleMode === 'persistent'
|
||||
? platform.shouldShow()
|
||||
: false
|
||||
: intelligentToggle,
|
||||
)
|
||||
const shouldShow = $shouldShow.value
|
||||
const toggleBodyIndent = React.useCallback(() => {
|
||||
if (sidebarToggleMode === 'persistent') {
|
||||
DOMHelper.setBodyIndent(shouldShow)
|
||||
} else {
|
||||
DOMHelper.setBodyIndent(false)
|
||||
}
|
||||
|
||||
if (shouldShow) {
|
||||
DOMHelper.focusFileExplorer() // TODO: verify if it works
|
||||
}
|
||||
}, [shouldShow, sidebarToggleMode])
|
||||
|
||||
React.useEffect(() => {
|
||||
toggleBodyIndent()
|
||||
}, [toggleBodyIndent])
|
||||
|
||||
useOnPJAXDone(toggleBodyIndent)
|
||||
|
||||
// Save expand state on toggle if auto expand is off
|
||||
React.useEffect(() => {
|
||||
if (intelligentToggle !== null) {
|
||||
configContext.onChange({ intelligentToggle: shouldShow })
|
||||
}
|
||||
}, [shouldShow, intelligentToggle])
|
||||
usePJAXAPI()
|
||||
platform.usePlatformHooks?.()
|
||||
useMarkGitakoReadyState()
|
||||
|
||||
const error = useLoadedContext(SideBarErrorContext).value
|
||||
// Lock shouldShow on error
|
||||
React.useEffect(() => {
|
||||
if (error && shouldShow) {
|
||||
$shouldShow.onChange(false)
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const setShowSideBar = React.useCallback(
|
||||
(show: typeof $shouldShow.value) => {
|
||||
if (!error) $shouldShow.onChange(show)
|
||||
},
|
||||
[error],
|
||||
const [shouldExpand, setShouldExpand, toggleShowSideBar] = useShouldExpand()
|
||||
|
||||
const configContext = useConfigs()
|
||||
|
||||
const blockLeaveRef = React.useRef(false)
|
||||
const { sidebarToggleMode } = configContext.value
|
||||
const onMouseLeaveSideBar: React.MouseEventHandler<HTMLElement> = React.useCallback(() => {
|
||||
if (blockLeaveRef.current) return
|
||||
if (sidebarToggleMode === 'float') setShouldExpand(false)
|
||||
}, [sidebarToggleMode, setShouldExpand])
|
||||
const onResizeStateChange = React.useCallback((state: ResizeState) => {
|
||||
blockLeaveRef.current = state === 'resizing'
|
||||
}, [])
|
||||
|
||||
const heightForSafari = useConditionalHook(
|
||||
() => detectBrowser() === 'Safari',
|
||||
() => useWindowSize().height, // eslint-disable-line react-hooks/rules-of-hooks
|
||||
)
|
||||
|
||||
const toggleShowSideBar = React.useCallback(() => {
|
||||
if (!error) $shouldShow.onChange(show => !show)
|
||||
}, [error])
|
||||
useToggleSideBarWithKeyboard(state, configContext, toggleShowSideBar)
|
||||
|
||||
const updateSideBarVisibility = React.useCallback(() => {
|
||||
if (intelligentToggle === null && sidebarToggleMode === 'persistent') {
|
||||
setShowSideBar(platform.shouldShow())
|
||||
}
|
||||
}, [intelligentToggle, sidebarToggleMode])
|
||||
|
||||
usePJAX()
|
||||
useOnPJAXDone(updateSideBarVisibility)
|
||||
|
||||
platform.usePlatformHooks?.()
|
||||
|
||||
// Hide sidebar when error due to auth but token is set #128
|
||||
const hideSidebarOnInvalidToken: boolean =
|
||||
intelligentToggle === null && Boolean(state === 'error-due-to-auth' && accessToken)
|
||||
React.useEffect(() => {
|
||||
if (hideSidebarOnInvalidToken) {
|
||||
setShowSideBar(false)
|
||||
}
|
||||
}, [hideSidebarOnInvalidToken])
|
||||
|
||||
return (
|
||||
<Theme>
|
||||
<IIFC>
|
||||
{() => {
|
||||
const logoContainerElement = useLogoContainerElement()
|
||||
return (
|
||||
<Portal into={logoContainerElement}>
|
||||
<ToggleShowButton
|
||||
error={error}
|
||||
className={cx({
|
||||
hidden: shouldExpand,
|
||||
})}
|
||||
onHover={sidebarToggleMode === 'float' ? () => setShouldExpand(true) : undefined}
|
||||
onClick={toggleShowSideBar}
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}}
|
||||
</IIFC>
|
||||
<div className={'gitako-side-bar'}>
|
||||
<Portal into={$logoContainerElement.value}>
|
||||
<ToggleShowButton
|
||||
error={error}
|
||||
className={cx({
|
||||
hidden: shouldShow,
|
||||
})}
|
||||
onHover={sidebarToggleMode === 'float' ? () => setShowSideBar(true) : undefined}
|
||||
onClick={toggleShowSideBar}
|
||||
/>
|
||||
</Portal>
|
||||
<SideBarBodyWrapper
|
||||
className={cx(`toggle-mode-${sidebarToggleMode}`, {
|
||||
collapsed: error || !shouldShow,
|
||||
<div
|
||||
className={cx('gitako-side-bar-body-wrapper', `toggle-mode-${sidebarToggleMode}`, {
|
||||
collapsed: error || !shouldExpand,
|
||||
})}
|
||||
baseSize={baseSize}
|
||||
onLeave={sidebarToggleMode === 'float' ? () => setShowSideBar(false) : undefined}
|
||||
sizeVariableMountPoint={sidebarToggleMode === 'persistent' ? document.body : undefined}
|
||||
style={{ height: heightForSafari }}
|
||||
onMouseLeave={onMouseLeaveSideBar}
|
||||
>
|
||||
<div className={'gitako-side-bar-body'}>
|
||||
<div
|
||||
className={'gitako-side-bar-content'}
|
||||
onClick={showSettings ? toggleShowSettings : undefined}
|
||||
>
|
||||
<div className={'gitako-side-bar-content'}>
|
||||
<div className={'header'}>
|
||||
<div className={'close-side-bar-button-position'}>
|
||||
<div className={'side-bar-position-controls'}>
|
||||
{sidebarToggleMode === 'persistent' && (
|
||||
<button
|
||||
title={'Collapse sidebar'}
|
||||
className={'close-side-bar-button'}
|
||||
<RoundIconButton
|
||||
icon={TabIcon}
|
||||
aria-label={'Collapse sidebar'}
|
||||
sx={{
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
onClick={toggleShowSideBar}
|
||||
>
|
||||
<Icon className={'action-icon'} type={'tab'} />
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
title={'Toggle sidebar dock mode between float and persistent'}
|
||||
className={cx('close-side-bar-button', {
|
||||
active: sidebarToggleMode === 'persistent',
|
||||
})}
|
||||
<RoundIconButton
|
||||
icon={PinIcon}
|
||||
aria-label={'Toggle sidebar dock mode between float and persistent'}
|
||||
iconColor={sidebarToggleMode === 'persistent' ? 'fg.default' : undefined}
|
||||
sx={{
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
onClick={() =>
|
||||
configContext.onChange({
|
||||
sidebarToggleMode: sidebarToggleMode === 'float' ? 'persistent' : 'float',
|
||||
sidebarToggleMode:
|
||||
sidebarToggleMode === 'persistent' ? 'float' : 'persistent',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon className={'action-icon'} type={'pin'} />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
{metaData && <MetaBar metaData={metaData} />}
|
||||
<MetaBar />
|
||||
</div>
|
||||
{run(() => {
|
||||
switch (state) {
|
||||
case 'disabled':
|
||||
return null
|
||||
case 'getting-access-token':
|
||||
return <LoadingIndicator text={'Getting access token...'} />
|
||||
case 'after-getting-access-token':
|
||||
case 'meta-loading':
|
||||
return <LoadingIndicator text={'Fetching repo meta...'} />
|
||||
case 'error-due-to-auth':
|
||||
return <AccessDeniedDescription />
|
||||
default:
|
||||
return (
|
||||
metaData && (
|
||||
<IIFC>
|
||||
{() => (
|
||||
<FileExplorer
|
||||
metaData={metaData}
|
||||
freeze={showSettings}
|
||||
accessToken={accessToken}
|
||||
config={configContext.value}
|
||||
catchNetworkErrors={useCatchNetworkError()}
|
||||
/>
|
||||
)}
|
||||
</IIFC>
|
||||
)
|
||||
)
|
||||
}
|
||||
})}
|
||||
<IIFC>
|
||||
{() => {
|
||||
switch (useLoadedContext(SideBarStateContext).value) {
|
||||
case 'getting-access-token':
|
||||
return <LoadingIndicator text={'Getting access token...'} />
|
||||
case 'after-getting-access-token':
|
||||
case 'meta-loading':
|
||||
return <LoadingIndicator text={'Fetching repo meta...'} />
|
||||
case 'error-due-to-auth':
|
||||
return <AccessDeniedDescription />
|
||||
case 'meta-loaded':
|
||||
case 'tree-loading':
|
||||
case 'tree-rendering':
|
||||
case 'tree-rendered':
|
||||
return <FileExplorer />
|
||||
}
|
||||
}}
|
||||
</IIFC>
|
||||
</div>
|
||||
<SettingsBar toggleShowSettings={toggleShowSettings} activated={showSettings} />
|
||||
<IIFC>
|
||||
{() => {
|
||||
const [showSettings, setShowSettings] = React.useState(false)
|
||||
const toggleShowSettings = React.useCallback(
|
||||
() => setShowSettings(show => !show),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSettings && <SettingsBarContent toggleShow={toggleShowSettings} />}
|
||||
<Footer toggleShowSettings={toggleShowSettings} />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</IIFC>
|
||||
</div>
|
||||
</SideBarBodyWrapper>
|
||||
{features.resize && <SideBarResizeHandler onResizeStateChange={onResizeStateChange} />}
|
||||
</div>
|
||||
</div>
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
|
||||
function useMarkGitakoReadyState() {
|
||||
React.useEffect(() => {
|
||||
DOMHelper.markGitakoReadyState(true)
|
||||
return () => DOMHelper.markGitakoReadyState(false)
|
||||
}, [])
|
||||
}
|
||||
|
||||
function useLogoContainerElement() {
|
||||
const [logoContainerElement, setLogoContainerElement] = React.useState<HTMLElement | null>(null)
|
||||
React.useEffect(() => {
|
||||
setLogoContainerElement(DOMHelper.insertLogoMountPoint())
|
||||
}, [])
|
||||
return logoContainerElement
|
||||
}
|
||||
|
||||
function useUpdateBodyIndentOnStateUpdate(shouldExpand: boolean) {
|
||||
const { sidebarToggleMode } = useConfigs().value
|
||||
React.useEffect(() => {
|
||||
if (sidebarToggleMode === 'persistent' && shouldExpand) {
|
||||
DOMHelper.setBodyIndent(true)
|
||||
return () => DOMHelper.setBodyIndent(false)
|
||||
}
|
||||
}, [sidebarToggleMode, shouldExpand])
|
||||
}
|
||||
|
||||
const getDerivedExpansion = ({
|
||||
intelligentToggle,
|
||||
sidebarToggleMode,
|
||||
}: Pick<Config, 'intelligentToggle' | 'sidebarToggleMode'>) =>
|
||||
sidebarToggleMode === 'persistent'
|
||||
? intelligentToggle === null // auto-expand checked
|
||||
? platform.shouldExpandSideBar()
|
||||
: intelligentToggle // read saved expand state
|
||||
: false // do not expand in float mode
|
||||
|
||||
function useGetDerivedExpansion() {
|
||||
const { intelligentToggle, sidebarToggleMode } = useConfigs().value
|
||||
return React.useCallback(
|
||||
() => getDerivedExpansion({ intelligentToggle, sidebarToggleMode }),
|
||||
[intelligentToggle, sidebarToggleMode],
|
||||
)
|
||||
}
|
||||
|
||||
function useUpdateBodyIndentAfterRedirect(update: (shouldExpand: boolean) => void) {
|
||||
const { intelligentToggle, sidebarToggleMode } = useConfigs().value
|
||||
useAfterRedirect(
|
||||
React.useCallback(() => {
|
||||
// check and update expand state if pinned and auto-expand checked
|
||||
if (sidebarToggleMode === 'persistent') {
|
||||
const shouldExpand = getDerivedExpansion({ intelligentToggle, sidebarToggleMode })
|
||||
update(shouldExpand)
|
||||
// Below DOM mutation cannot be omitted, if do, body indent may get lost when shouldExpand is true for both before & after redirecting
|
||||
DOMHelper.setBodyIndent(shouldExpand)
|
||||
}
|
||||
}, [update, sidebarToggleMode, intelligentToggle]),
|
||||
)
|
||||
}
|
||||
|
||||
// Save expand state on toggle if auto expand is off
|
||||
function useSaveExpandStateOnToggle(shouldExpand: boolean) {
|
||||
const configContext = useConfigs()
|
||||
const { intelligentToggle } = configContext.value
|
||||
React.useEffect(() => {
|
||||
if (intelligentToggle !== null) configContext.onChange({ intelligentToggle: shouldExpand })
|
||||
}, [shouldExpand, intelligentToggle]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
function useToggleSideBarWithKeyboard(toggleShowSideBar: () => void) {
|
||||
const { shortcut } = useConfigs().value
|
||||
const state = useLoadedContext(SideBarStateContext).value
|
||||
const isDisabled = state === 'disabled' || !shortcut
|
||||
React.useEffect(
|
||||
function attachKeyDown() {
|
||||
if (isDisabled) return
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const keys = keyHelper.parseEvent(e)
|
||||
if (keys === shortcut) toggleShowSideBar()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
},
|
||||
[toggleShowSideBar, isDisabled, shortcut],
|
||||
)
|
||||
}
|
||||
|
||||
function useCollapseOnNoPermissionWhenTokenHasBeenSet(
|
||||
setShowSideBar: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) {
|
||||
const { accessToken, intelligentToggle, sidebarToggleMode } = useConfigs().value
|
||||
const state = useLoadedContext(SideBarStateContext).value
|
||||
const hideSidebarOnInvalidToken =
|
||||
sidebarToggleMode === 'persistent' &&
|
||||
intelligentToggle === null &&
|
||||
!!accessToken &&
|
||||
state === 'error-due-to-auth'
|
||||
React.useEffect(() => {
|
||||
if (hideSidebarOnInvalidToken) setShowSideBar(false)
|
||||
}, [hideSidebarOnInvalidToken, setShowSideBar])
|
||||
}
|
||||
|
||||
function useShouldExpand() {
|
||||
const getDerivedExpansion = useGetDerivedExpansion()
|
||||
const [shouldExpand, setShouldExpand] = React.useState(getDerivedExpansion)
|
||||
const toggleShowSideBar = React.useCallback(
|
||||
() => setShouldExpand(show => !show),
|
||||
[setShouldExpand],
|
||||
)
|
||||
|
||||
useSaveExpandStateOnToggle(shouldExpand)
|
||||
useUpdateBodyIndentOnStateUpdate(shouldExpand)
|
||||
useUpdateBodyIndentAfterRedirect(setShouldExpand)
|
||||
useToggleSideBarWithKeyboard(toggleShowSideBar)
|
||||
useCollapseOnNoPermissionWhenTokenHasBeenSet(setShouldExpand)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldExpand) DOMHelper.focusFileExplorer() // TODO: verify if it works
|
||||
}, [shouldExpand])
|
||||
|
||||
return [shouldExpand, setShouldExpand, toggleShowSideBar] as const
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
import { ResizeHandler } from 'components/ResizeHandler'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { useDebounce, useWindowSize } from 'react-use'
|
||||
import { getDefaultConfigs } from 'utils/config/helper'
|
||||
import { cx } from 'utils/cx'
|
||||
import { setCSSVariable } from 'utils/DOMHelper'
|
||||
import * as features from 'utils/features'
|
||||
import { detectBrowser } from 'utils/general'
|
||||
import { useConditionalHook } from '../utils/hooks/useConditionalHook'
|
||||
|
||||
type Size = number
|
||||
export type Size2D = [Size, Size]
|
||||
type Props = {
|
||||
baseSize: Size
|
||||
className?: string
|
||||
onLeave?: React.HTMLAttributes<HTMLElement>['onMouseLeave']
|
||||
sizeVariableMountPoint?: HTMLElement
|
||||
}
|
||||
|
||||
const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100
|
||||
const MINIMAL_WIDTH = 240
|
||||
|
||||
function getSafeSize(size: number, width: number) {
|
||||
if (size > width - MINIMAL_CONTENT_VIEWPORT_WIDTH) return width - MINIMAL_CONTENT_VIEWPORT_WIDTH
|
||||
if (size < MINIMAL_WIDTH) return MINIMAL_WIDTH
|
||||
return size
|
||||
}
|
||||
|
||||
export function SideBarBodyWrapper({
|
||||
baseSize,
|
||||
className,
|
||||
children,
|
||||
onLeave,
|
||||
sizeVariableMountPoint,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const [size, setSize] = React.useState(baseSize)
|
||||
const configContext = useConfigs()
|
||||
const blockLeaveRef = React.useRef(false)
|
||||
|
||||
const heightForSafari = useConditionalHook(
|
||||
() => detectBrowser() === 'Safari',
|
||||
() => useWindowSize().height,
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
setSize(baseSize)
|
||||
}, [baseSize])
|
||||
|
||||
const { width } = useWindowSize()
|
||||
React.useEffect(() => {
|
||||
const safeSize = getSafeSize(size, width)
|
||||
if (safeSize !== size) setSize(safeSize)
|
||||
}, [width, size])
|
||||
const bodyWrapperRef = React.useRef<HTMLDivElement | null>(null)
|
||||
useDebounce(() => configContext.onChange({ sideBarWidth: size }), 100, [size])
|
||||
|
||||
function apply(sizeVariableMountPoint: HTMLElement | undefined, size: number) {
|
||||
if (sizeVariableMountPoint)
|
||||
setCSSVariable(
|
||||
'--gitako-width',
|
||||
sizeVariableMountPoint ? `${size}px` : undefined,
|
||||
sizeVariableMountPoint,
|
||||
)
|
||||
|
||||
if (bodyWrapperRef.current)
|
||||
setCSSVariable(
|
||||
'--gitako-width',
|
||||
sizeVariableMountPoint ? undefined : `${size}px`,
|
||||
bodyWrapperRef.current,
|
||||
)
|
||||
}
|
||||
|
||||
// Update size using useEffect would cause delay
|
||||
const onResize = React.useMemo(() => {
|
||||
let sizeToApply: number,
|
||||
applied = true
|
||||
return ([size]: number[]) => {
|
||||
// do NOT merge this with the above similar effect, side bar will jump otherwise
|
||||
sizeToApply = getSafeSize(size, width)
|
||||
setSize(sizeToApply)
|
||||
|
||||
if (applied) {
|
||||
applied = false
|
||||
requestAnimationFrame(() => {
|
||||
applied = true
|
||||
apply(sizeVariableMountPoint, sizeToApply)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [width, sizeVariableMountPoint])
|
||||
|
||||
React.useEffect(() => {
|
||||
apply(sizeVariableMountPoint, size)
|
||||
}, [sizeVariableMountPoint])
|
||||
|
||||
const onMouseLeave = React.useCallback(
|
||||
e => {
|
||||
if (blockLeaveRef.current) return
|
||||
onLeave?.(e)
|
||||
},
|
||||
[onLeave],
|
||||
)
|
||||
|
||||
const dummySize: [number, number] = React.useMemo(() => [size, size], [size])
|
||||
|
||||
const defaultSideBarWidth = React.useMemo(() => getDefaultConfigs().sideBarWidth, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={bodyWrapperRef}
|
||||
className={cx('gitako-side-bar-body-wrapper', className)}
|
||||
style={{ height: heightForSafari }}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<div className={'gitako-side-bar-body-wrapper-content'}>{children}</div>
|
||||
{features.resize && (
|
||||
<ResizeHandler
|
||||
onResize={onResize}
|
||||
onResetSize={() => {
|
||||
setSize(defaultSideBarWidth)
|
||||
apply(sizeVariableMountPoint, defaultSideBarWidth)
|
||||
}}
|
||||
onResizeStateChange={state => {
|
||||
blockLeaveRef.current = state === 'resizing'
|
||||
}}
|
||||
size={dummySize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
src/components/SideBarResizeHandler.tsx
Normal file
85
src/components/SideBarResizeHandler.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { useDebounce, useWindowSize } from 'react-use'
|
||||
import { getDefaultConfigs } from 'utils/config/helper'
|
||||
import * as DOMHelper from 'utils/DOMHelper'
|
||||
import { useAfterRedirect } from 'utils/hooks/useFastRedirect'
|
||||
import { ResizeHandler } from './ResizeHandler'
|
||||
import { Size, Size2D } from './Size'
|
||||
|
||||
const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100
|
||||
const MINIMAL_WIDTH = 240
|
||||
|
||||
function getSafeWidth(width: Size, windowWidth: number) {
|
||||
if (width > windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH)
|
||||
return windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH
|
||||
if (width < MINIMAL_WIDTH) return MINIMAL_WIDTH
|
||||
return width
|
||||
}
|
||||
|
||||
function useSidebarWidth() {
|
||||
const configContext = useConfigs()
|
||||
|
||||
// size data flow:
|
||||
// windowSize.width => width
|
||||
// width => config.sideBarWidth
|
||||
// width => --gitako-width // layout effect
|
||||
// resize event => width
|
||||
// resize event => --gitako-width // rAF
|
||||
const [width, setWidth] = React.useState(configContext.value.sideBarWidth)
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
React.useEffect(() => {
|
||||
const safeSize = getSafeWidth(width, windowWidth)
|
||||
if (safeSize !== width) setWidth(safeSize)
|
||||
}, [windowWidth, width])
|
||||
useDebounce(() => configContext.onChange({ sideBarWidth: width }), 100, [width])
|
||||
|
||||
React.useLayoutEffect(() => DOMHelper.setGitakoWidthCSSVariable(width), [width])
|
||||
|
||||
// Keep variable when directing from PR to repo home via meta bar
|
||||
useAfterRedirect(React.useCallback(() => DOMHelper.setGitakoWidthCSSVariable(width), [width]))
|
||||
|
||||
return [width, setWidth] as const
|
||||
}
|
||||
|
||||
export function SideBarResizeHandler({
|
||||
onResizeStateChange,
|
||||
}: Pick<React.ComponentProps<typeof ResizeHandler>, 'onResizeStateChange'>) {
|
||||
const [width, setWidth] = useSidebarWidth()
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
const onResize = React.useMemo(() => {
|
||||
let widthToApply: Size
|
||||
let pending = false
|
||||
return ([width]: Size2D) => {
|
||||
// do NOT merge this with the above similar effect
|
||||
widthToApply = width
|
||||
|
||||
if (!pending) {
|
||||
pending = true
|
||||
// Update size using useEffect would cause delay
|
||||
requestAnimationFrame(() => {
|
||||
pending = false
|
||||
widthToApply = getSafeWidth(widthToApply, windowWidth)
|
||||
DOMHelper.setGitakoWidthCSSVariable(widthToApply)
|
||||
setWidth(widthToApply)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [windowWidth, setWidth])
|
||||
|
||||
const onResetSize = React.useCallback(
|
||||
() => setWidth(getDefaultConfigs().sideBarWidth),
|
||||
[setWidth],
|
||||
)
|
||||
|
||||
const dummySize: Size2D = React.useMemo(() => [width, 0], [width])
|
||||
|
||||
return (
|
||||
<ResizeHandler
|
||||
onResize={onResize}
|
||||
onResetSize={onResetSize}
|
||||
onResizeStateChange={onResizeStateChange}
|
||||
size={dummySize}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { Config } from 'utils/config/helper'
|
||||
import { Field } from './settings/Field'
|
||||
|
||||
export type SimpleField<Key extends keyof Config> = {
|
||||
key: Key
|
||||
label: string
|
||||
wikiLink?: string
|
||||
tooltip?: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
overwrite?: {
|
||||
value: (value: Config[Key]) => boolean
|
||||
onChange: (checked: boolean) => Config[Key]
|
||||
}
|
||||
}
|
||||
|
||||
type Props<Key extends keyof Config> = {
|
||||
field: SimpleField<Key>
|
||||
|
||||
onChange?(): void
|
||||
}
|
||||
|
||||
export function SimpleToggleField<Key extends keyof Config>({ field, onChange }: Props<Key>) {
|
||||
const { overwrite } = field
|
||||
const configContext = useConfigs()
|
||||
const value = configContext.value[field.key]
|
||||
return (
|
||||
<Field
|
||||
id={field.key}
|
||||
title={
|
||||
<>
|
||||
{field.label}{' '}
|
||||
{field.wikiLink ? (
|
||||
<a href={field.wikiLink} title={field.tooltip} target={'_blank'}>
|
||||
(?)
|
||||
</a>
|
||||
) : field.description ? (
|
||||
<p className={'note'} title={field.tooltip}>
|
||||
{field.description}
|
||||
</p>
|
||||
) : (
|
||||
field.tooltip && (
|
||||
<span className={'help'} title={field.tooltip}>
|
||||
(?)
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
className={'field-checkbox'}
|
||||
checkbox
|
||||
>
|
||||
<input
|
||||
id={field.key}
|
||||
name={field.key}
|
||||
disabled={field.disabled}
|
||||
type={'checkbox'}
|
||||
onChange={async e => {
|
||||
const enabled = e.currentTarget.checked
|
||||
configContext.onChange({ [field.key]: overwrite ? overwrite.onChange(enabled) : enabled })
|
||||
if (onChange) onChange()
|
||||
}}
|
||||
checked={overwrite ? overwrite.value(value) : Boolean(value)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
2
src/components/Size.tsx
Normal file
2
src/components/Size.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type Size = number
|
||||
export type Size2D = [Size, Size]
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import * as features from 'utils/features'
|
||||
|
||||
type Size = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type?: string | React.ComponentType
|
||||
children(size: Partial<Size>): React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLElement>
|
||||
|
||||
export function SizeObserver({ type = 'div', children, ...rest }: Props) {
|
||||
const ref = React.useRef<any>()
|
||||
|
||||
const [size, setSize] = React.useState<Partial<Size>>({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (features.resize) {
|
||||
const observer = new window.ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
const rect = entry.contentRect
|
||||
setSize(rect)
|
||||
})
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
} else {
|
||||
if ('getBoundingClientRect' in ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setSize(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const props: any = { ...rest, ref } // :)
|
||||
|
||||
return React.createElement(type, props, children(size))
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { SyncIcon } from '@primer/octicons-react'
|
||||
import iconURL from 'assets/icons/Gitako.png'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { ReloadContext } from 'containers/ReloadContext'
|
||||
import * as React from 'react'
|
||||
import { useDebounce, useWindowSize } from 'react-use'
|
||||
import { cx } from 'utils/cx'
|
||||
import { useResizeHandler } from 'utils/hooks/useResizeHandler'
|
||||
import { Icon } from './Icon'
|
||||
import { RoundIconButton } from './RoundIconButton'
|
||||
|
||||
type Props = {
|
||||
error?: string | null
|
||||
|
|
@ -20,6 +22,7 @@ function getSafeDistance(y: number, height: number) {
|
|||
}
|
||||
|
||||
export function ToggleShowButton({ error, className, onClick, onHover }: Props) {
|
||||
const reload = React.useContext(ReloadContext)
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
const config = useConfigs()
|
||||
const [distance, setDistance] = React.useState(config.value.toggleButtonVerticalDistance)
|
||||
|
|
@ -38,11 +41,11 @@ export function ToggleShowButton({ error, className, onClick, onHover }: Props)
|
|||
)
|
||||
|
||||
// reposition on window height change, but ignores distance change
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.style.top = distance + 'px'
|
||||
}
|
||||
}, [height])
|
||||
}, [height]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// And this repositions on drag
|
||||
const { onPointerDown } = useResizeHandler(
|
||||
|
|
@ -67,13 +70,21 @@ export function ToggleShowButton({ error, className, onClick, onHover }: Props)
|
|||
onPointerDown={onPointerDown}
|
||||
title={'Gitako (draggable)'}
|
||||
>
|
||||
{config.value.toggleButtonContent === 'octoface' ? (
|
||||
<Icon className={'octoface-icon'} type={'octoface'} />
|
||||
) : (
|
||||
<img className={'tentacle'} draggable={false} src={iconURL} />
|
||||
)}
|
||||
<img className={'tentacle'} draggable={false} src={iconURL} />
|
||||
</button>
|
||||
{error && <span className={'error-message'}>{error}</span>}
|
||||
{error && (
|
||||
<span className={'error-message'}>
|
||||
{error}
|
||||
<RoundIconButton
|
||||
sx={{ ml: 1 }}
|
||||
variant="danger"
|
||||
size="small"
|
||||
aria-label={'Reload Gitako'}
|
||||
icon={SyncIcon}
|
||||
onClick={reload}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { fuzzyMode } from './fuzzyMode'
|
||||
|
||||
type TreeNodeSource = {
|
||||
[key: string]: true | TreeNodeSource
|
||||
}
|
||||
|
||||
function createTreeNode(source: TreeNodeSource, name: string = '', paths: string[] = []): TreeNode {
|
||||
function createTreeNode(source: TreeNodeSource, name = '', paths: string[] = []): TreeNode {
|
||||
const subPaths = paths.concat(name)
|
||||
return {
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function fuzzyMatch(input: string, sample: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function fuzzyMatchIndexes(input: string, sample: string, shift: number = 0) {
|
||||
function fuzzyMatchIndexes(input: string, sample: string, shift = 0) {
|
||||
const indexes: number[] = []
|
||||
let i = 0,
|
||||
j = 0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Text, TextInput } from '@primer/components'
|
||||
import { Button, Text, TextInput } from '@primer/react'
|
||||
import { wikiLinks } from 'components/settings/SettingsBar'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { platform } from 'platforms'
|
||||
|
|
@ -10,31 +10,29 @@ import { SettingsSection } from './SettingsSection'
|
|||
|
||||
const ACCESS_TOKEN_REGEXP = /^([0-9a-fA-F]+|gh[pousr]_[A-Za-z0-9_]+)$/
|
||||
|
||||
type Props = {}
|
||||
|
||||
export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
|
||||
export function AccessTokenSettings() {
|
||||
const configContext = useConfigs()
|
||||
const hasAccessToken = Boolean(configContext.value.accessToken)
|
||||
const useAccessToken = useStateIO('')
|
||||
const { accessToken } = configContext.value
|
||||
const hasAccessToken = Boolean(accessToken)
|
||||
const [accessTokenInputValue, setAccessTokenInputValue] = React.useState('')
|
||||
const useAccessTokenHint = useStateIO<React.ReactNode>('')
|
||||
const focusInput = useStateIO(false)
|
||||
|
||||
const { value: accessTokenHint } = useAccessTokenHint
|
||||
const { value: accessToken } = useAccessToken
|
||||
|
||||
React.useEffect(() => {
|
||||
// clear input when access token updates
|
||||
useAccessToken.onChange('')
|
||||
}, [configContext.value.accessToken])
|
||||
setAccessTokenInputValue('')
|
||||
}, [accessToken])
|
||||
|
||||
const onInputAccessToken = React.useCallback(
|
||||
({ currentTarget: { value } }: React.FormEvent<HTMLInputElement>) => {
|
||||
useAccessToken.onChange(value)
|
||||
setAccessTokenInputValue(value)
|
||||
useAccessTokenHint.onChange(
|
||||
ACCESS_TOKEN_REGEXP.test(value) ? '' : 'Gitako does not recognize the token.',
|
||||
)
|
||||
},
|
||||
[],
|
||||
[], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
|
||||
const saveToken = React.useCallback(
|
||||
|
|
@ -48,13 +46,13 @@ export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
|
|||
</span>
|
||||
),
|
||||
) => {
|
||||
if (accessToken) {
|
||||
configContext.onChange({ accessToken })
|
||||
useAccessToken.onChange('')
|
||||
if (accessTokenInputValue) {
|
||||
configContext.onChange({ accessToken: accessTokenInputValue })
|
||||
setAccessTokenInputValue('')
|
||||
useAccessTokenHint.onChange(hint)
|
||||
}
|
||||
},
|
||||
[accessToken],
|
||||
[accessTokenInputValue], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
|
||||
const onPressAccessToken = React.useCallback(
|
||||
|
|
@ -73,6 +71,7 @@ export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
|
|||
href={wikiLinks.createAccessToken}
|
||||
title="A token is required to access private repositories or bypass API rate limits"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
(?)
|
||||
</a>
|
||||
|
|
@ -109,16 +108,16 @@ export function AccessTokenSettings(props: React.PropsWithChildren<Props>) {
|
|||
)}
|
||||
<div className={'access-token-input-control'}>
|
||||
<TextInput
|
||||
marginRight={1}
|
||||
sx={{ marginRight: 1 }}
|
||||
className={'access-token-input'}
|
||||
value={accessToken}
|
||||
value={accessTokenInputValue}
|
||||
placeholder="Or input here manually"
|
||||
onFocus={() => focusInput.onChange(true)}
|
||||
onBlur={() => focusInput.onChange(false)}
|
||||
onChange={onInputAccessToken}
|
||||
onKeyPress={onPressAccessToken}
|
||||
/>
|
||||
<Button onClick={() => saveToken()} disabled={!accessToken}>
|
||||
<Button onClick={() => saveToken()} disabled={!accessTokenInputValue}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import { cx } from 'utils/cx'
|
||||
|
||||
type Props = {
|
||||
id?: string
|
||||
className?: string
|
||||
checkbox?: boolean
|
||||
title: React.ReactNode
|
||||
}
|
||||
|
||||
export function Field({
|
||||
className,
|
||||
title,
|
||||
id,
|
||||
checkbox,
|
||||
children,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className={cx('field', className)}>
|
||||
{checkbox ? (
|
||||
<>
|
||||
{children}
|
||||
<label htmlFor={id}>{title}</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor={id}>{title}</label>
|
||||
<div>{children}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { wikiLinks } from 'components/settings/SettingsBar'
|
||||
import { SimpleToggleField } from 'components/SimpleToggleField'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { SimpleConfigFieldCheckbox } from 'components/settings/SimpleConfigField/Checkbox'
|
||||
import * as React from 'react'
|
||||
import { Config } from 'utils/config/helper'
|
||||
import { Option, SelectInput } from '../SelectInput'
|
||||
import { Field } from './Field'
|
||||
import { Option } from '../Inputs/SelectInput'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
import { SimpleConfigFieldSelect } from './SimpleConfigField/SelectInput'
|
||||
|
||||
const iconOptions: Option<Config['icons']>[] = [
|
||||
{
|
||||
|
|
@ -38,37 +37,24 @@ const recursiveToggleFolderOptions: Option<Config['recursiveToggleFolder']>[] =
|
|||
},
|
||||
]
|
||||
|
||||
type Props = {}
|
||||
|
||||
export function FileTreeSettings(props: React.PropsWithChildren<Props>) {
|
||||
const configContext = useConfigs()
|
||||
export function FileTreeSettings() {
|
||||
return (
|
||||
<SettingsSection title={'File Tree'}>
|
||||
<Field id="recursive-toggle-folder" title="Toggle folders recursively while holding">
|
||||
<SelectInput
|
||||
id="recursive-toggle-folder"
|
||||
options={recursiveToggleFolderOptions}
|
||||
onChange={v => {
|
||||
configContext.onChange({
|
||||
recursiveToggleFolder: v,
|
||||
})
|
||||
}}
|
||||
value={configContext.value.recursiveToggleFolder}
|
||||
/>
|
||||
</Field>
|
||||
<Field title="Icons" id="file-tree-icons">
|
||||
<SelectInput<Config['icons']>
|
||||
id="file-tree-icons"
|
||||
options={iconOptions}
|
||||
onChange={v => {
|
||||
configContext.onChange({
|
||||
icons: v,
|
||||
})
|
||||
}}
|
||||
value={configContext.value.icons}
|
||||
/>
|
||||
</Field>
|
||||
<SimpleToggleField
|
||||
<SimpleConfigFieldSelect
|
||||
field={{
|
||||
key: 'recursiveToggleFolder',
|
||||
label: 'Toggle folders recursively while holding',
|
||||
}}
|
||||
options={recursiveToggleFolderOptions}
|
||||
/>
|
||||
<SimpleConfigFieldSelect
|
||||
field={{
|
||||
key: 'icons',
|
||||
label: 'Icons',
|
||||
}}
|
||||
options={iconOptions}
|
||||
/>
|
||||
<SimpleConfigFieldCheckbox
|
||||
field={{
|
||||
key: 'compressSingletonFolder',
|
||||
label: 'Compress singleton folder',
|
||||
|
|
@ -76,28 +62,21 @@ export function FileTreeSettings(props: React.PropsWithChildren<Props>) {
|
|||
tooltip: 'Merge folders and their only child folder to make UI more compact.',
|
||||
}}
|
||||
/>
|
||||
<SimpleToggleField
|
||||
<SimpleConfigFieldCheckbox
|
||||
field={{
|
||||
key: 'restoreExpandedFolders',
|
||||
label: 'Restore expanded folders',
|
||||
tooltip: 'Folders will be expanded again when clear search input',
|
||||
}}
|
||||
/>
|
||||
<SimpleToggleField
|
||||
<SimpleConfigFieldCheckbox
|
||||
field={{
|
||||
key: 'commentToggle',
|
||||
label: 'Show PR file comments',
|
||||
tooltip: 'Show number of comments next to file names in Pull Requests.',
|
||||
}}
|
||||
/>
|
||||
<SimpleToggleField
|
||||
field={{
|
||||
key: 'showDiffInText',
|
||||
label: 'Show PR file diff in text',
|
||||
tooltip: 'Glance diff in a more precise way.',
|
||||
}}
|
||||
/>
|
||||
<SimpleToggleField
|
||||
<SimpleConfigFieldCheckbox
|
||||
field={{
|
||||
key: 'compactFileTree',
|
||||
label: 'Compact file tree layout',
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { Link } from '@primer/components'
|
||||
import { Icon } from 'components/Icon'
|
||||
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 { VERSION } from 'env'
|
||||
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 { SimpleField, SimpleToggleField } from '../SimpleToggleField'
|
||||
import { AccessTokenSettings } from './AccessTokenSettings'
|
||||
import { FileTreeSettings } from './FileTreeSettings'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
import { SidebarSettings } from './SidebarSettings'
|
||||
import { SimpleConfigField } from './SimpleConfigField'
|
||||
import { SimpleConfigFieldCheckbox } from './SimpleConfigField/Checkbox'
|
||||
|
||||
const WIKI_HOME_LINK = 'https://github.com/EnixCoda/Gitako/wiki'
|
||||
export const wikiLinks = {
|
||||
|
|
@ -24,59 +26,64 @@ export const wikiLinks = {
|
|||
pjaxMode: `${WIKI_HOME_LINK}/Pjax-Mode`,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
activated: boolean
|
||||
toggleShowSettings: () => void
|
||||
}
|
||||
const moreFields: SimpleConfigField<
|
||||
'copyFileButton' | 'copySnippetButton' | 'codeFolding' | 'pjaxMode'
|
||||
>[] =
|
||||
platform === GitHub
|
||||
? [
|
||||
{
|
||||
key: 'codeFolding',
|
||||
label: 'Fold source code button',
|
||||
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',
|
||||
wikiLink: wikiLinks.copyFileButton,
|
||||
tooltip: `Read more in Gitako's Wiki`,
|
||||
},
|
||||
{
|
||||
key: 'copySnippetButton',
|
||||
label: 'Copy snippet button',
|
||||
wikiLink: wikiLinks.copySnippet,
|
||||
tooltip: `Read more in Gitako's Wiki`,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
function SettingsBarContent() {
|
||||
export function SettingsBarContent({ toggleShow }: { toggleShow: () => void }) {
|
||||
const useReloadHint = useStateIO<React.ReactNode>('')
|
||||
const { value: reloadHint } = useReloadHint
|
||||
|
||||
const moreFields: SimpleField<
|
||||
'copyFileButton' | 'copySnippetButton' | 'codeFolding' | 'pjaxMode'
|
||||
>[] =
|
||||
platform === GitHub
|
||||
? [
|
||||
{
|
||||
key: 'codeFolding',
|
||||
label: 'Fold source code button',
|
||||
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',
|
||||
wikiLink: wikiLinks.copyFileButton,
|
||||
tooltip: `Read more in Gitako's Wiki`,
|
||||
},
|
||||
{
|
||||
key: 'copySnippetButton',
|
||||
label: 'Copy snippet button',
|
||||
wikiLink: wikiLinks.copySnippet,
|
||||
tooltip: `Read more in Gitako's Wiki`,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
useUpdateEffect(() => {
|
||||
window.location.reload()
|
||||
}, [useConfigs().value.pjaxMode])
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className={'gitako-settings-bar-title'}>Settings</h2>
|
||||
<div className={'gitako-settings-bar-content'}>
|
||||
<div className={'gitako-settings-bar'}>
|
||||
<div className={'gitako-settings-bar-header'}>
|
||||
<h2 className={'gitako-settings-bar-title'}>Settings</h2>
|
||||
<RoundIconButton
|
||||
aria-label="Close settings"
|
||||
onClick={toggleShow}
|
||||
size="medium"
|
||||
iconSize={20}
|
||||
icon={ChevronDownIcon}
|
||||
color="fg.default"
|
||||
/>
|
||||
</div>
|
||||
<Box display="grid" gridGap={4} className={'gitako-settings-bar-content'}>
|
||||
<div className={'shadow-shelter'} />
|
||||
<AccessTokenSettings />
|
||||
<SidebarSettings />
|
||||
|
|
@ -85,7 +92,7 @@ function SettingsBarContent() {
|
|||
<SettingsSection title={'More'}>
|
||||
{moreFields.map(field => (
|
||||
<React.Fragment key={field.key}>
|
||||
<SimpleToggleField field={field} />
|
||||
<SimpleConfigFieldCheckbox field={field} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
|
|
@ -93,44 +100,26 @@ function SettingsBarContent() {
|
|||
</SettingsSection>
|
||||
)}
|
||||
<SettingsSection title={'Talk to the author'}>
|
||||
<a href="https://github.com/EnixCoda/Gitako/issues" target="_blank">
|
||||
Report bug
|
||||
</a>
|
||||
{' / '}
|
||||
<a href="https://github.com/EnixCoda/Gitako/discussions" target="_blank">
|
||||
Discuss feature
|
||||
</a>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/EnixCoda/Gitako/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Report bug
|
||||
</a>
|
||||
{' / '}
|
||||
<a
|
||||
href="https://github.com/EnixCoda/Gitako/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discuss feature
|
||||
</a>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsBar(props: Props) {
|
||||
const { toggleShowSettings, activated } = props
|
||||
return (
|
||||
<div className={'gitako-settings-bar'}>
|
||||
{activated && <SettingsBarContent />}
|
||||
<div className={'header-row'}>
|
||||
<Link
|
||||
className={'version'}
|
||||
fontSize={14}
|
||||
href={wikiLinks.changeLog}
|
||||
target={'_blank'}
|
||||
title={'Check out new features!'}
|
||||
>
|
||||
{VERSION}
|
||||
</Link>
|
||||
<div className={'header-right'}>
|
||||
<button className={'settings-button'} onClick={toggleShowSettings}>
|
||||
{activated ? (
|
||||
<Icon type={'chevron-down'} className={'hide-settings-icon'} />
|
||||
) : (
|
||||
<Icon type={'gear'} className={'show-settings-icon'} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<Footer toggleShowSettings={toggleShow} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { Box } from '@primer/react'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
title?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SettingsSection({ title, children }: React.PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className={'settings-section'}>
|
||||
<Box display="grid" gridGap={'2px'}>
|
||||
{title && <h3>{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,108 +1,28 @@
|
|||
import { Button, TextInput } from '@primer/components'
|
||||
import { Option, SelectInput } from 'components/SelectInput'
|
||||
import { SimpleToggleField } from 'components/SimpleToggleField'
|
||||
import { Box, Button, FormControl, TextInput } from '@primer/react'
|
||||
import { SimpleConfigFieldCheckbox } from 'components/settings/SimpleConfigField/Checkbox'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { Config } from 'utils/config/helper'
|
||||
import { friendlyFormatShortcut } from 'utils/general'
|
||||
import { friendlyFormatShortcut, noop } from 'utils/general'
|
||||
import { useStateIO } from 'utils/hooks/useStateIO'
|
||||
import * as keyHelper from 'utils/keyHelper'
|
||||
import { Field } from './Field'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
|
||||
type Props = {}
|
||||
|
||||
const toggleButtonContentOptions: Option<Config['toggleButtonContent']>[] = [
|
||||
{
|
||||
key: 'logo',
|
||||
value: 'logo',
|
||||
label: `Gitako Logo`,
|
||||
},
|
||||
{
|
||||
key: 'octoface',
|
||||
value: 'octoface',
|
||||
label: `The Classic Octoface`,
|
||||
},
|
||||
]
|
||||
|
||||
export function SidebarSettings(props: React.PropsWithChildren<Props>) {
|
||||
const configContext = useConfigs()
|
||||
const useToggleShowSideBarShortcut = useStateIO(configContext.value.shortcut)
|
||||
const { value: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
|
||||
const focused = useStateIO(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
useToggleShowSideBarShortcut.onChange(configContext.value.shortcut)
|
||||
}, [configContext.value.shortcut])
|
||||
export function SidebarSettings() {
|
||||
const { sidebarToggleMode } = useConfigs().value
|
||||
|
||||
return (
|
||||
<SettingsSection title={'Sidebar'}>
|
||||
<Field id="toggle-sidebar-shortcut" title="Keyboard shortcut to toggle visibility">
|
||||
<div className={'toggle-shortcut-input-control'}>
|
||||
<TextInput
|
||||
id="toggle-sidebar-shortcut"
|
||||
marginRight={1}
|
||||
className={'toggle-shortcut-input'}
|
||||
onFocus={() => focused.onChange(true)}
|
||||
onBlur={() => focused.onChange(false)}
|
||||
placeholder={focused.value ? 'Press key combination' : 'Click here to set'}
|
||||
value={friendlyFormatShortcut(toggleShowSideBarShortcut)}
|
||||
onKeyDown={React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// Clear shortcut with backspace
|
||||
const shortcut = e.key === 'Backspace' ? '' : keyHelper.parseEvent(e)
|
||||
useToggleShowSideBarShortcut.onChange(shortcut)
|
||||
}, [])}
|
||||
readOnly
|
||||
/>
|
||||
{configContext.value.shortcut === toggleShowSideBarShortcut ? (
|
||||
<Button
|
||||
disabled={!configContext.value.shortcut}
|
||||
onClick={() => {
|
||||
configContext.onChange({ shortcut: '' })
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const { value: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
|
||||
if (typeof toggleShowSideBarShortcut !== 'string') return
|
||||
configContext.onChange({ shortcut: toggleShowSideBarShortcut })
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field id="toggle-button-content" title="Icon of the toggle button">
|
||||
<SelectInput
|
||||
id="toggle-button-content"
|
||||
options={toggleButtonContentOptions}
|
||||
onChange={v => {
|
||||
configContext.onChange({
|
||||
toggleButtonContent: v,
|
||||
})
|
||||
}}
|
||||
value={configContext.value.toggleButtonContent}
|
||||
/>
|
||||
</Field>
|
||||
<SimpleToggleField
|
||||
<ToggleSidebarShortcutSettings />
|
||||
<SimpleConfigFieldCheckbox
|
||||
field={{
|
||||
key: 'intelligentToggle',
|
||||
label: 'Auto expand',
|
||||
disabled: configContext.value.sidebarToggleMode === 'float',
|
||||
disabled: sidebarToggleMode === 'float',
|
||||
tooltip: `Gitako will expand when exploring source files, pull requests, etc. And collapse otherwise.${
|
||||
configContext.value.sidebarToggleMode === 'float'
|
||||
? '\nNow disabled as sidebar is in float mode.'
|
||||
: ''
|
||||
sidebarToggleMode === 'float' ? '\nNow disabled as sidebar is in float mode.' : ''
|
||||
}`,
|
||||
overwrite: {
|
||||
value: enabled =>
|
||||
configContext.value.sidebarToggleMode === 'float' ? false : enabled === null,
|
||||
value: enabled => (sidebarToggleMode === 'float' ? false : enabled === null),
|
||||
onChange: checked => (checked ? null : true),
|
||||
},
|
||||
}}
|
||||
|
|
@ -110,3 +30,61 @@ export function SidebarSettings(props: React.PropsWithChildren<Props>) {
|
|||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleSidebarShortcutSettings() {
|
||||
const configContext = useConfigs()
|
||||
const { shortcut } = configContext.value
|
||||
const id = 'toggle-show-sidebar-shortcut'
|
||||
|
||||
React.useEffect(() => {
|
||||
useToggleShowSideBarShortcut.onChange(shortcut)
|
||||
}, [shortcut]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const useToggleShowSideBarShortcut = useStateIO(shortcut)
|
||||
const { value: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
|
||||
const focused = useStateIO(false)
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<FormControl.Label htmlFor={id}>Keyboard shortcut to toggle visibility</FormControl.Label>
|
||||
<Box display="inline-flex" width="100%">
|
||||
<TextInput
|
||||
id={id}
|
||||
sx={{ marginRight: 1, flex: 1 }}
|
||||
onFocus={() => focused.onChange(true)}
|
||||
onBlur={() => focused.onChange(false)}
|
||||
placeholder={focused.value ? 'Press key combination' : 'Click here to set'}
|
||||
value={friendlyFormatShortcut(toggleShowSideBarShortcut)}
|
||||
onChange={noop}
|
||||
onKeyDown={React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// Clear shortcut with backspace
|
||||
const shortcut = e.key === 'Backspace' ? '' : keyHelper.parseEvent(e)
|
||||
useToggleShowSideBarShortcut.onChange(shortcut)
|
||||
}, [])} // eslint-disable-line react-hooks/exhaustive-deps
|
||||
/>
|
||||
{shortcut === toggleShowSideBarShortcut ? (
|
||||
<Button
|
||||
disabled={!shortcut}
|
||||
onClick={() => {
|
||||
configContext.onChange({ shortcut: '' })
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const { value: toggleShowSideBarShortcut } = useToggleShowSideBarShortcut
|
||||
if (typeof toggleShowSideBarShortcut !== 'string') return
|
||||
configContext.onChange({ shortcut: toggleShowSideBarShortcut })
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
20
src/components/settings/SimpleConfigField/Checkbox.tsx
Normal file
20
src/components/settings/SimpleConfigField/Checkbox.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as React from 'react'
|
||||
import { ConfigKeys } from 'utils/config/helper'
|
||||
import { SimpleConfigFieldProps, useSimpleConfigFieldIO } from '.'
|
||||
import { Checkbox } from '../../Inputs/Checkbox'
|
||||
import { FieldLabel } from './FieldLabel'
|
||||
|
||||
export function SimpleConfigFieldCheckbox<Key extends ConfigKeys>({
|
||||
field,
|
||||
}: SimpleConfigFieldProps<Key>) {
|
||||
const { value, onChange } = useSimpleConfigFieldIO(field) as IO<boolean>
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
label={<FieldLabel {...field} />}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
44
src/components/settings/SimpleConfigField/FieldLabel.tsx
Normal file
44
src/components/settings/SimpleConfigField/FieldLabel.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { InfoIcon, LinkExternalIcon } from '@primer/octicons-react'
|
||||
import { Box } from '@primer/react'
|
||||
import * as React from 'react'
|
||||
import { ConfigKeys } from 'utils/config/helper'
|
||||
import { SimpleConfigField } from '.'
|
||||
|
||||
export function FieldLabel<Key extends ConfigKeys>({
|
||||
label,
|
||||
wikiLink,
|
||||
tooltip,
|
||||
}: SimpleConfigField<Key>) {
|
||||
return (
|
||||
<>
|
||||
{label}
|
||||
{(wikiLink || tooltip) && ' '}
|
||||
{wikiLink ? (
|
||||
<a
|
||||
className={'help'}
|
||||
href={wikiLink}
|
||||
title={tooltip}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkExternalIcon size="small" />
|
||||
</a>
|
||||
) : (
|
||||
tooltip && (
|
||||
<Box
|
||||
as="span"
|
||||
sx={{
|
||||
'.octicon': {
|
||||
color: 'fg.subtle',
|
||||
},
|
||||
}}
|
||||
className={'help'}
|
||||
title={tooltip}
|
||||
>
|
||||
<InfoIcon size="small" />
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
src/components/settings/SimpleConfigField/SelectInput.tsx
Normal file
24
src/components/settings/SimpleConfigField/SelectInput.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { SelectInput, SelectInputProps } from 'components/Inputs/SelectInput'
|
||||
import * as React from 'react'
|
||||
import { Config, ConfigKeys } from 'utils/config/helper'
|
||||
import { SimpleConfigFieldProps, useSimpleConfigFieldIO } from '.'
|
||||
import { FieldLabel } from './FieldLabel'
|
||||
|
||||
export function SimpleConfigFieldSelect<Key extends ConfigKeys>({
|
||||
field,
|
||||
options,
|
||||
}: SimpleConfigFieldProps<Key> & {
|
||||
options: SelectInputProps<Config[Key]>['options']
|
||||
}) {
|
||||
const { value, onChange } = useSimpleConfigFieldIO(field)
|
||||
|
||||
return (
|
||||
<SelectInput
|
||||
label={<FieldLabel {...field} />}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
)
|
||||
}
|
||||
37
src/components/settings/SimpleConfigField/index.tsx
Normal file
37
src/components/settings/SimpleConfigField/index.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import React from 'react'
|
||||
import { Config, ConfigKeys } from 'utils/config/helper'
|
||||
|
||||
export type SimpleConfigField<Key extends ConfigKeys> = {
|
||||
label: string
|
||||
wikiLink?: string
|
||||
tooltip?: string
|
||||
key: Key
|
||||
disabled?: boolean
|
||||
overwrite?: {
|
||||
value: (value: Config[Key]) => Config[Key]
|
||||
onChange: (newValue: Config[Key]) => Config[Key]
|
||||
}
|
||||
}
|
||||
|
||||
export type SimpleConfigFieldProps<Key extends ConfigKeys> = {
|
||||
field: SimpleConfigField<Key>
|
||||
}
|
||||
|
||||
export function useSimpleConfigFieldIO<Key extends ConfigKeys>(
|
||||
field: SimpleConfigField<Key>,
|
||||
): IO<Config[Key]> {
|
||||
const { overwrite } = field
|
||||
const configContext = useConfigs()
|
||||
const value = configContext.value[field.key]
|
||||
|
||||
return {
|
||||
value: React.useMemo(() => (overwrite ? overwrite.value(value) : value), [overwrite, value]),
|
||||
onChange: React.useCallback(
|
||||
(newValue: Config[Key]) => {
|
||||
configContext.onChange({ [field.key]: overwrite ? overwrite.onChange(newValue) : newValue })
|
||||
},
|
||||
[field.key, overwrite, configContext],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -29,9 +29,9 @@ export function ConfigsContextWrapper(props: React.PropsWithChildren<Props>) {
|
|||
)
|
||||
}
|
||||
|
||||
export const useConfigs = useNonNullContext(ConfigsContext)
|
||||
export const useConfigs = createUseNonNullContext(ConfigsContext)
|
||||
|
||||
function useNonNullContext<T>(theContext: React.Context<T | null>): () => T {
|
||||
function createUseNonNullContext<T>(theContext: React.Context<T | null>): () => T {
|
||||
return () => {
|
||||
const context = React.useContext(theContext)
|
||||
if (context === null) throw new Error(`Empty context`)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import * as React from 'react'
|
||||
|
||||
export class ErrorBoundary extends React.PureComponent {
|
||||
export class ErrorBoundary extends React.PureComponent<React.PropsWithChildren<{}>> {
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
raiseError(error, errorInfo)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useInspector } from 'containers/StateInspector'
|
||||
import * as React from 'react'
|
||||
import { useStateIO } from 'utils/hooks/useStateIO'
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ export const SideBarErrorContext = React.createContext<SideBarErrorContextShape
|
|||
|
||||
export function StateBarErrorContextWrapper({ children }: React.PropsWithChildren<{}>) {
|
||||
const $error = useStateIO<string | null>(null)
|
||||
useInspector('SideBarErrorContext', $error.value)
|
||||
|
||||
return <SideBarErrorContext.Provider value={$error}>{children}</SideBarErrorContext.Provider>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function OAuthWrapper({ children }: React.PropsWithChildren<{}>) {
|
|||
if (needGetAccessTokenRef.current) {
|
||||
$state.onChange(running ? 'getting-access-token' : 'after-getting-access-token')
|
||||
}
|
||||
}, [running])
|
||||
}, [running]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// block children rendering on the first render if setting token
|
||||
if (running && $state.value !== 'getting-access-token') return null
|
||||
|
|
@ -38,7 +38,7 @@ function useGetAccessToken() {
|
|||
}
|
||||
$block.onChange(false)
|
||||
})
|
||||
}, [])
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return $block.value
|
||||
}
|
||||
|
|
|
|||
17
src/containers/ReloadContext.tsx
Normal file
17
src/containers/ReloadContext.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as React from 'react'
|
||||
import { noop } from 'utils/general'
|
||||
|
||||
export type ReloadContextShape = () => void
|
||||
|
||||
export const ReloadContext = React.createContext<ReloadContextShape>(noop)
|
||||
|
||||
export function ReloadContextWrapper({ children }: React.PropsWithChildren<{}>) {
|
||||
const [key, setKey] = React.useState(0)
|
||||
const reload = React.useCallback(() => setKey(key => key + 1), [])
|
||||
|
||||
return (
|
||||
<ReloadContext.Provider key={key} value={reload}>
|
||||
{children}
|
||||
</ReloadContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ import { useConfigs } from 'containers/ConfigsContext'
|
|||
import { platform } from 'platforms'
|
||||
import * as React from 'react'
|
||||
import { useEffectOnSerializableUpdates } from 'utils/hooks/useEffectOnSerializableUpdates'
|
||||
import { useAfterRedirect } from 'utils/hooks/useFastRedirect'
|
||||
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
|
||||
import { useOnPJAXDone } from 'utils/hooks/usePJAX'
|
||||
import { useStateIO } from 'utils/hooks/useStateIO'
|
||||
import { useCatchNetworkError } from '../utils/hooks/useCatchNetworkError'
|
||||
import { SideBarStateContext } from './SideBarState'
|
||||
|
|
@ -14,6 +14,8 @@ export function RepoContextWrapper({ children }: React.PropsWithChildren<{}>) {
|
|||
const partialMetaData = usePartialMetaData()
|
||||
const defaultBranch = useDefaultBranch(partialMetaData)
|
||||
const metaData = useMetaData(partialMetaData, defaultBranch)
|
||||
const state = useLoadedContext(SideBarStateContext).value
|
||||
if (state === 'disabled') return null
|
||||
|
||||
return <RepoContext.Provider value={metaData}>{children}</RepoContext.Provider>
|
||||
}
|
||||
|
|
@ -27,9 +29,9 @@ function resolvePartialMetaData() {
|
|||
repoName,
|
||||
type: type === 'pull' ? type : undefined,
|
||||
}
|
||||
} else {
|
||||
return partialMetaData
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function usePartialMetaData(): PartialMetaData | null {
|
||||
|
|
@ -38,11 +40,14 @@ function usePartialMetaData(): PartialMetaData | null {
|
|||
// sync along URL and DOM
|
||||
const $partialMetaData = useStateIO(isGettingAccessToken ? null : resolvePartialMetaData)
|
||||
const $committedPartialMetaData = useStateIO($partialMetaData.value)
|
||||
const setPartialMetaData = () => $partialMetaData.onChange(resolvePartialMetaData())
|
||||
const setPartialMetaData = React.useCallback(
|
||||
() => $partialMetaData.onChange(resolvePartialMetaData()),
|
||||
[], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
React.useEffect(() => {
|
||||
if (!isGettingAccessToken) setPartialMetaData()
|
||||
}, [isGettingAccessToken])
|
||||
useOnPJAXDone(setPartialMetaData)
|
||||
}, [isGettingAccessToken, setPartialMetaData])
|
||||
useAfterRedirect(setPartialMetaData)
|
||||
useEffectOnSerializableUpdates(
|
||||
$partialMetaData.value,
|
||||
JSON.stringify,
|
||||
|
|
@ -52,14 +57,14 @@ function usePartialMetaData(): PartialMetaData | null {
|
|||
if (!$partialMetaData.value && !isGettingAccessToken) {
|
||||
$state.onChange('disabled')
|
||||
}
|
||||
}, [$partialMetaData.value])
|
||||
}, [$partialMetaData.value]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return $committedPartialMetaData.value
|
||||
}
|
||||
|
||||
function useBranchName(): MetaData['branchName'] | null {
|
||||
// sync along URL and DOM
|
||||
const $branchName = useStateIO(() => platform.resolvePartialMetaData()?.branchName || null)
|
||||
useOnPJAXDone(() => $branchName.onChange(platform.resolvePartialMetaData()?.branchName || null))
|
||||
useAfterRedirect(() => $branchName.onChange(platform.resolvePartialMetaData()?.branchName || null))
|
||||
return $branchName.value
|
||||
}
|
||||
|
||||
|
|
@ -72,11 +77,13 @@ function useDefaultBranch(partialMetaData: PartialMetaData | null) {
|
|||
catchNetworkError(async () => {
|
||||
if (!partialMetaData) return
|
||||
$state.onChange('meta-loading')
|
||||
const { userName, repoName } = partialMetaData
|
||||
if (!userName || !repoName) return
|
||||
|
||||
const defaultBranch = await platform.getDefaultBranchName(partialMetaData, accessToken)
|
||||
const defaultBranch = await platform.getDefaultBranchName({ userName, repoName }, accessToken)
|
||||
$defaultBranch.onChange(defaultBranch)
|
||||
})
|
||||
}, [partialMetaData, accessToken])
|
||||
}, [partialMetaData, accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return $defaultBranch.value
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +98,8 @@ function useMetaData(
|
|||
React.useEffect(() => {
|
||||
if (partialMetaData && defaultBranchName && theBranch) {
|
||||
const { userName, repoName } = partialMetaData
|
||||
if (!userName || !repoName) return
|
||||
|
||||
const safeMetaData: MetaData = {
|
||||
userName,
|
||||
repoName,
|
||||
|
|
@ -102,6 +111,6 @@ function useMetaData(
|
|||
} else {
|
||||
$metaData.onChange(null)
|
||||
}
|
||||
}, [partialMetaData, defaultBranchName, theBranch])
|
||||
}, [partialMetaData, defaultBranchName, theBranch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return $metaData.value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { useStateIO } from 'utils/hooks/useStateIO'
|
||||
import { useInspector } from './StateInspector'
|
||||
|
||||
export type SideBarState =
|
||||
| 'disabled'
|
||||
|
|
@ -19,6 +20,7 @@ export const SideBarStateContext = React.createContext<SideBarStateContextShape
|
|||
|
||||
export function StateBarStateContextWrapper({ children }: React.PropsWithChildren<{}>) {
|
||||
const $state = useStateIO<SideBarState>('disabled')
|
||||
useInspector('SideBarStateContext', $state.value)
|
||||
|
||||
return (
|
||||
<SideBarStateContext.Provider value={$state}>
|
||||
|
|
|
|||
58
src/containers/StateInspector.tsx
Normal file
58
src/containers/StateInspector.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { ReactIO } from 'common'
|
||||
import { IN_PRODUCTION_MODE } from 'env'
|
||||
import * as React from 'react'
|
||||
import { useStateIO } from 'utils/hooks/useStateIO'
|
||||
|
||||
export type InspectorContextShape = ReactIO<JSONObject>
|
||||
|
||||
export const InspectorContext = React.createContext<InspectorContextShape | null>(null)
|
||||
|
||||
export const InspectorContextWrapper = IN_PRODUCTION_MODE
|
||||
? React.Fragment
|
||||
: function InspectorContextWrapper({ children }: React.PropsWithChildren<{}>) {
|
||||
const $ = useStateIO<JSONObject>({})
|
||||
const [show, setShow] = React.useState(true)
|
||||
|
||||
return (
|
||||
<InspectorContext.Provider value={$}>
|
||||
{show ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
right: '0',
|
||||
height: '100vh',
|
||||
width: '360px',
|
||||
overflow: 'auto',
|
||||
background: 'rgba(255, 255, 255, 0.75)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<pre style={{ flex: 1 }}>{JSON.stringify($.value, null, 2)}</pre>
|
||||
<div>
|
||||
<button onClick={() => setShow(false)}>❌</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '0',
|
||||
right: '0',
|
||||
}}
|
||||
>
|
||||
<button onClick={() => setShow(true)}>🔎</button>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</InspectorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useInspector(key: string, value: JSONValue) {
|
||||
const $ = React.useContext(InspectorContext)
|
||||
React.useEffect(() => {
|
||||
$?.onChange(prev => ({ ...prev, [key]: value }))
|
||||
}, [key, value]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
|
@ -1,268 +1,58 @@
|
|||
import { theme } from '@primer/components'
|
||||
import primitives from '@primer/primitives'
|
||||
import { BaseStyles, ThemeProvider } from '@primer/react'
|
||||
import theme from '@primer/react/lib-esm/theme'
|
||||
import * as React from 'react'
|
||||
import { ThemeProvider } from 'styled-components'
|
||||
const { lighten, rgba, desaturate } = require('polished')
|
||||
|
||||
// Refactored from https://github.com/primer/components/blob/main/src/theme-preval.js
|
||||
export function generateDarkTheme() {
|
||||
const primitives = {
|
||||
black: '#f0f6fc',
|
||||
white: '#010409',
|
||||
gray: [
|
||||
'#0d1117',
|
||||
'#161b22',
|
||||
'#21262d',
|
||||
'#30363d',
|
||||
'#484f58',
|
||||
'#6e7681',
|
||||
'#8b949e',
|
||||
'#b1bac4',
|
||||
'#c9d1d9',
|
||||
'#f0f6fc',
|
||||
],
|
||||
blue: [
|
||||
'#051d4d',
|
||||
'#0c2d6b',
|
||||
'#0d419d',
|
||||
'#1158c7',
|
||||
'#1f6feb',
|
||||
'#388bfd',
|
||||
'#58a6ff',
|
||||
'#79c0ff',
|
||||
'#a5d6ff',
|
||||
'#cae8ff',
|
||||
],
|
||||
green: [
|
||||
'#04260f',
|
||||
'#033a16',
|
||||
'#0f5323',
|
||||
'#196c2e',
|
||||
'#238636',
|
||||
'#2ea043',
|
||||
'#3fb950',
|
||||
'#56d364',
|
||||
'#7ee787',
|
||||
'#aff5b4',
|
||||
],
|
||||
yellow: [
|
||||
'#341a00',
|
||||
'#4b2900',
|
||||
'#693e00',
|
||||
'#845306',
|
||||
'#9e6a03',
|
||||
'#bb8009',
|
||||
'#d29922',
|
||||
'#e3b341',
|
||||
'#f2cc60',
|
||||
'#f8e3a1',
|
||||
],
|
||||
orange: [
|
||||
'#3d1300',
|
||||
'#5a1e02',
|
||||
'#762d0a',
|
||||
'#9b4215',
|
||||
'#bd561d',
|
||||
'#db6d28',
|
||||
'#f0883e',
|
||||
'#ffa657',
|
||||
'#ffc680',
|
||||
'#ffdfb6',
|
||||
],
|
||||
red: [
|
||||
'#490202',
|
||||
'#67060c',
|
||||
'#8e1519',
|
||||
'#b62324',
|
||||
'#da3633',
|
||||
'#f85149',
|
||||
'#ff7b72',
|
||||
'#ffa198',
|
||||
'#ffc1ba',
|
||||
'#ffdcd7',
|
||||
],
|
||||
purple: [
|
||||
'#271052',
|
||||
'#3c1e70',
|
||||
'#553098',
|
||||
'#6e40c9',
|
||||
'#8957e5',
|
||||
'#a371f7',
|
||||
'#bc8cff',
|
||||
'#d2a8ff',
|
||||
'#e2c5ff',
|
||||
'#eddeff',
|
||||
],
|
||||
pink: [
|
||||
'#42062a',
|
||||
'#5e103e',
|
||||
'#7d2457',
|
||||
'#9e3670',
|
||||
'#bf4b8a',
|
||||
'#db61a2',
|
||||
'#f778ba',
|
||||
'#ff9bce',
|
||||
'#ffbedd',
|
||||
'#ffdaec',
|
||||
],
|
||||
}
|
||||
|
||||
const { black, white, pink, gray, blue, green, orange, purple, red, yellow } = primitives
|
||||
|
||||
// General
|
||||
// any :(
|
||||
const colors: any = {
|
||||
black,
|
||||
white,
|
||||
gray,
|
||||
blue,
|
||||
green,
|
||||
orange,
|
||||
purple,
|
||||
red,
|
||||
yellow,
|
||||
pink,
|
||||
}
|
||||
|
||||
colors.bodytext = gray[9]
|
||||
|
||||
colors.blackfade15 = rgba(black, 0.15)
|
||||
colors.blackfade30 = rgba(black, 0.3)
|
||||
colors.blackfade50 = rgba(black, 0.5)
|
||||
colors.blackfade70 = rgba(black, 0.7)
|
||||
colors.blackfade85 = rgba(black, 0.85)
|
||||
colors.whitefade15 = rgba(white, 0.15)
|
||||
colors.whitefade30 = rgba(white, 0.3)
|
||||
colors.whitefade50 = rgba(white, 0.5)
|
||||
colors.whitefade70 = rgba(white, 0.7)
|
||||
colors.whitefade85 = rgba(white, 0.85)
|
||||
|
||||
colors.state = {
|
||||
error: red[5],
|
||||
failure: red[5],
|
||||
pending: yellow[7],
|
||||
queued: yellow[7],
|
||||
success: green[5],
|
||||
unknown: gray[4],
|
||||
}
|
||||
|
||||
colors.border = {
|
||||
blackFade: colors.blackfade15,
|
||||
blue: blue[5],
|
||||
blueLight: blue[2],
|
||||
grayLight: lighten(0.03, gray[2]),
|
||||
gray: gray[2],
|
||||
grayDark: gray[3],
|
||||
grayDarker: gray[7],
|
||||
green: green[4],
|
||||
greenLight: desaturate(0.4, green[3]),
|
||||
purple: purple[5],
|
||||
red: red[5],
|
||||
redLight: desaturate(0.6, red[3]),
|
||||
white,
|
||||
whiteFade: colors.whitefade15,
|
||||
yellow: desaturate(0.6, yellow[3]),
|
||||
}
|
||||
|
||||
colors.text = {
|
||||
white,
|
||||
gray: gray[6],
|
||||
grayLight: gray[5],
|
||||
grayDark: gray[8],
|
||||
red: red[6],
|
||||
}
|
||||
colors.bg = {
|
||||
gray: gray[1],
|
||||
grayLight: gray[3],
|
||||
grayDark: gray[9],
|
||||
disabled: gray[2],
|
||||
}
|
||||
colors.accent = orange[5]
|
||||
colors.labels = {
|
||||
gray: gray[2],
|
||||
grayText: gray[9],
|
||||
grayDark: gray[5],
|
||||
grayDarkText: gray[9],
|
||||
blue: blue[5],
|
||||
blueText: blue[5],
|
||||
orange: orange[5],
|
||||
orangeText: orange[6],
|
||||
green: green[5],
|
||||
greenText: green[6],
|
||||
red: red[6],
|
||||
redText: red[6],
|
||||
yellow: yellow[6],
|
||||
yellowText: yellow[9],
|
||||
pink: pink[4],
|
||||
pinkText: pink[6],
|
||||
purple: purple[4],
|
||||
}
|
||||
|
||||
// Components
|
||||
|
||||
const buttons = {
|
||||
default: {
|
||||
color: {
|
||||
default: colors.text.grayDark,
|
||||
disabled: gray[4],
|
||||
},
|
||||
border: {
|
||||
default: rgba(black, 0.12),
|
||||
active: colors.border.grayDark,
|
||||
disabled: colors.border.grayLight,
|
||||
},
|
||||
bg: {
|
||||
default: colors.bg.grayLight,
|
||||
hover: colors.gray[3],
|
||||
active: colors.bg.gray,
|
||||
disabled: colors.bg.grayLight,
|
||||
},
|
||||
shadow: {
|
||||
default: `0 0 transparent, 0 0 transparent`,
|
||||
hover: `0 0 transparent, 0 0 transparent`,
|
||||
active: `0 0 transparent 0 0 transparent`,
|
||||
focus: `0 0 transparent 0 0 transparent`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const theme = {
|
||||
colors,
|
||||
buttons,
|
||||
}
|
||||
|
||||
return theme
|
||||
// Temporary color fix for out-of-date embedded @primer/primitives in @primer/react
|
||||
// The `*_tritanopia` themes are actually not bundled within @primer/react@35.2.2
|
||||
// TODO: Upgrade @primer/react to support these themes
|
||||
const fixedTheme = {
|
||||
...theme,
|
||||
colorSchemes: {
|
||||
...theme.colorSchemes,
|
||||
light_tritanopia: theme.colorSchemes.light,
|
||||
dark_tritanopia: theme.colorSchemes.dark,
|
||||
},
|
||||
}
|
||||
|
||||
const getIsPreferDarkTheme = () => {
|
||||
const colorMode = document.documentElement.dataset.colorMode
|
||||
return (
|
||||
colorMode === 'dark' ||
|
||||
(colorMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
)
|
||||
const validColorSchemes = Object.keys(fixedTheme.colorSchemes) as EnumString<
|
||||
keyof typeof primitives['colors']
|
||||
>[]
|
||||
|
||||
const colorModeMap: Record<string, 'night' | 'day'> = {
|
||||
dark: 'night',
|
||||
light: 'day',
|
||||
}
|
||||
// No need to watch the property as it does not change in repo pages
|
||||
function usePreferDarkTheme() {
|
||||
const [is, setIs] = React.useState(getIsPreferDarkTheme)
|
||||
|
||||
const getPreferenceFromDOM = () => {
|
||||
// <html lang="en" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark" ...
|
||||
// <html lang="en" data-color-mode="light" data-light-theme="light" data-dark-theme="dark" ...
|
||||
// <html lang="en" data-color-mode="dark" data-light-theme="light" data-dark-theme="dark" ...
|
||||
const { colorMode, lightTheme, darkTheme } = document.documentElement.dataset
|
||||
|
||||
return {
|
||||
colorMode: (colorMode && colorModeMap[colorMode]) || 'auto',
|
||||
dayScheme: lightTheme && validColorSchemes.includes(lightTheme) ? lightTheme : 'light',
|
||||
nightScheme: darkTheme && validColorSchemes.includes(darkTheme) ? darkTheme : 'dark',
|
||||
} as const
|
||||
}
|
||||
|
||||
function useThemePreference() {
|
||||
const [prefer, setPrefer] = React.useState(getPreferenceFromDOM)
|
||||
React.useEffect(() => {
|
||||
const update = () => {
|
||||
console.log(`updating color scheme`)
|
||||
setIs(getIsPreferDarkTheme())
|
||||
}
|
||||
const match = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const update = () => setPrefer(getPreferenceFromDOM)
|
||||
match.addEventListener('change', update)
|
||||
return () => match.removeEventListener('change', update)
|
||||
}, [])
|
||||
return is
|
||||
}
|
||||
|
||||
function useThemeConfig() {
|
||||
const preferDarkTheme = usePreferDarkTheme()
|
||||
return React.useMemo(() => {
|
||||
return preferDarkTheme ? generateDarkTheme() : theme
|
||||
}, [preferDarkTheme])
|
||||
return prefer
|
||||
}
|
||||
|
||||
export function Theme({ children }: React.PropsWithChildren<{}>) {
|
||||
const theme = useThemeConfig()
|
||||
return <ThemeProvider theme={theme}>{children}</ThemeProvider>
|
||||
const themePreference = useThemePreference()
|
||||
return (
|
||||
<ThemeProvider {...themePreference} theme={fixedTheme}>
|
||||
<BaseStyles>{children}</BaseStyles>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,18 @@
|
|||
import { withErrorLog } from 'analytics'
|
||||
import { Gitako } from 'components/Gitako'
|
||||
import { addMiddleware } from 'driver/connect'
|
||||
import { platform } from 'platforms'
|
||||
import * as React from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import {
|
||||
insertLogoMountPoint,
|
||||
insertSideBarMountPoint,
|
||||
persistGitakoElements
|
||||
} from 'utils/DOMHelper'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { insertSideBarMountPoint } from 'utils/DOMHelper'
|
||||
import './content.scss'
|
||||
|
||||
if (platform.resolvePartialMetaData()) {
|
||||
addMiddleware(withErrorLog)
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init)
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init)
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await injectStyles(browser.runtime.getURL('content.css'))
|
||||
const SideBarElement = insertSideBarMountPoint()
|
||||
const logoElement = insertLogoMountPoint()
|
||||
persistGitakoElements(SideBarElement, logoElement)
|
||||
ReactDOM.render(<Gitako />, SideBarElement)
|
||||
createRoot(insertSideBarMountPoint()).render(<Gitako />)
|
||||
}
|
||||
|
||||
// injects a copy of stylesheets so that other extensions(e.g. dark reader) could read
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
import * as React from 'react'
|
||||
|
||||
export type Method<Args extends any[] = any[]> = (...args: Args) => void | Promise<void>
|
||||
export type Middleware = <M extends Method, MM extends Method>(
|
||||
method: M,
|
||||
args: Parameters<M>,
|
||||
) => [MM | M, Parameters<MM | M>]
|
||||
|
||||
const middlewares: Middleware[] = []
|
||||
|
||||
export function addMiddleware(middleware: Middleware) {
|
||||
if (typeof middleware !== 'function') return null
|
||||
const m = middleware.bind(null)
|
||||
middlewares.push(m)
|
||||
return function removeMiddleware() {
|
||||
const index = middlewares.indexOf(m)
|
||||
if (index === -1) return
|
||||
middlewares.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const applyMiddlewares: Middleware = function applyMiddlewares(method, args) {
|
||||
for (const middleware of middlewares) {
|
||||
;[method = method, args = args] = middleware(method, args)
|
||||
}
|
||||
return [method, args]
|
||||
}
|
||||
|
||||
function run<M extends Method>([method, args]: [M, Parameters<M>]) {
|
||||
method.apply(null, args)
|
||||
}
|
||||
|
||||
export type DispatchState<Props, State> = React.Component<Props, State>['setState']
|
||||
export type GetState<Props, State> = () => { state: State; props: Props }
|
||||
export type TriggerOtherMethod<Props, State> = <Args extends any[]>(
|
||||
methodCreator: MethodCreator<Props, State, Args>,
|
||||
...args: Parameters<ReturnType<MethodCreator<Props, State, Args>>>
|
||||
) => void
|
||||
|
||||
export type Dispatch<Props, State> = {
|
||||
set: DispatchState<Props, State>
|
||||
get: GetState<Props, State>
|
||||
call: TriggerOtherMethod<Props, State>
|
||||
}
|
||||
|
||||
export type MethodCreator<Props, State, Args extends any[] = []> = (
|
||||
dispatch: Dispatch<Props, State>,
|
||||
) => Method<Args>
|
||||
|
||||
export type Sources<P, S> = {
|
||||
[key: string]: MethodCreator<P, S, any>
|
||||
}
|
||||
type WrappedMethods = {
|
||||
[key: string]: Method
|
||||
}
|
||||
|
||||
function link<P, S>(instance: React.Component<P, S>, sources: Sources<P, S>): WrappedMethods {
|
||||
const wrappedMethods: WrappedMethods = {
|
||||
/* [keyof sources] -> wrappedMethods.method */
|
||||
}
|
||||
const map = new Map<
|
||||
MethodCreator<P, S, any>,
|
||||
Method
|
||||
>(/* sources.creator -> wrappedMethods.method */)
|
||||
|
||||
const dispatchCall: TriggerOtherMethod<P, S> = (createMethod, ...otherArgs) => {
|
||||
const isFromSource = sourcesValues.includes(createMethod)
|
||||
if (isFromSource) {
|
||||
const method = map.get(createMethod)
|
||||
if (!method) throw new Error('Method not found')
|
||||
const runnable = applyMiddlewares(method, otherArgs)
|
||||
run(runnable)
|
||||
}
|
||||
}
|
||||
const dispatchState: DispatchState<P, S> = (updater, callback) => {
|
||||
instance.setState(updater, callback)
|
||||
}
|
||||
const prepareState: GetState<P, S> = () => ({ state: instance.state, props: instance.props })
|
||||
const dispatch: Dispatch<P, S> = {
|
||||
call: dispatchCall,
|
||||
get: prepareState,
|
||||
set: dispatchState,
|
||||
}
|
||||
|
||||
Object.entries(sources).forEach(([key, createMethod]) => {
|
||||
const method = createMethod(dispatch)
|
||||
wrappedMethods[key] = method
|
||||
map.set(createMethod, method)
|
||||
})
|
||||
|
||||
const sourcesValues = Object.values(sources)
|
||||
|
||||
return wrappedMethods
|
||||
}
|
||||
|
||||
export function connect<BaseP, ExtraP>(mapping: Sources<BaseP, ExtraP>) {
|
||||
return function linkComponent<State, ComponentType extends React.ComponentType<BaseP & ExtraP>>(
|
||||
Component: ComponentType,
|
||||
) {
|
||||
return class ConnectedComponent extends React.PureComponent<BaseP, ExtraP, State> {
|
||||
static displayName = `Connected(${Component.displayName || Component.name})`
|
||||
static defaultProps = Component.defaultProps
|
||||
|
||||
state: ExtraP = {} as ExtraP
|
||||
connectedMethods: WrappedMethods = link<BaseP, ExtraP>(this, mapping)
|
||||
|
||||
render() {
|
||||
const props = Object.assign({}, this.props, this.connectedMethods, this.state)
|
||||
return React.createElement(Component, props)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GetCreatedMethod<MC> = MC extends MethodCreator<infer P, infer S, infer Args>
|
||||
? Args extends any[]
|
||||
? (...args: Args) => void
|
||||
: never
|
||||
: never
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
import { SideBarStateContextShape } from 'containers/SideBarState'
|
||||
import { GetCreatedMethod, MethodCreator } from 'driver/connect'
|
||||
import { platform } from 'platforms'
|
||||
import { Config } from 'utils/config/helper'
|
||||
import * as DOMHelper from 'utils/DOMHelper'
|
||||
import { isOpenInNewWindowClick, OperatingSystems, os } from 'utils/general'
|
||||
import { loadWithPJAX } from 'utils/hooks/usePJAX'
|
||||
import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
|
||||
export type Props = {
|
||||
metaData: MetaData
|
||||
freeze: boolean
|
||||
accessToken: string | undefined
|
||||
config: Config
|
||||
catchNetworkErrors: <T>(fn: () => T) => Promise<T | undefined>
|
||||
}
|
||||
|
||||
export type ConnectorState = {
|
||||
visibleNodesGenerator: VisibleNodesGenerator | null
|
||||
visibleNodes: VisibleNodes | null
|
||||
searchKey: string
|
||||
searched: boolean // derived state from searchKey, = !!searchKey
|
||||
defer: boolean
|
||||
|
||||
handleKeyDown: GetCreatedMethod<typeof handleKeyDown>
|
||||
updateSearchKey: GetCreatedMethod<typeof updateSearchKey>
|
||||
onNodeClick: GetCreatedMethod<typeof onNodeClick>
|
||||
onFocusSearchBar: GetCreatedMethod<typeof onFocusSearchBar>
|
||||
setUpTree: GetCreatedMethod<typeof setUpTree>
|
||||
goTo: GetCreatedMethod<typeof goTo>
|
||||
expandTo: GetCreatedMethod<typeof expandTo>
|
||||
}
|
||||
|
||||
function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode) {
|
||||
let index = nodes.findIndex(node => node.path === focusedNode.path) - 1
|
||||
while (index >= 0) {
|
||||
if (nodes[index].contents?.includes(focusedNode)) {
|
||||
return nodes[index]
|
||||
}
|
||||
--index
|
||||
}
|
||||
}
|
||||
|
||||
type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, ConnectorState, Args>
|
||||
|
||||
export const setUpTree: BoundMethodCreator<
|
||||
[
|
||||
{ stateContext: SideBarStateContextShape } & Required<Pick<Props, 'metaData'>> & {
|
||||
config: Pick<Config, 'compressSingletonFolder' | 'accessToken'>
|
||||
},
|
||||
() => boolean,
|
||||
]
|
||||
> =
|
||||
dispatch =>
|
||||
({ stateContext, metaData, config }, checker) => {
|
||||
const {
|
||||
props: { catchNetworkErrors },
|
||||
} = dispatch.get()
|
||||
|
||||
catchNetworkErrors(async () => {
|
||||
const { userName, repoName, branchName } = metaData
|
||||
|
||||
if (!checker()) return
|
||||
stateContext.onChange('tree-loading')
|
||||
|
||||
const { root: treeRoot, defer = false } = await platform.getTreeData(
|
||||
{
|
||||
branchName,
|
||||
userName,
|
||||
repoName,
|
||||
},
|
||||
'/',
|
||||
true,
|
||||
config.accessToken,
|
||||
)
|
||||
|
||||
if (!checker()) return
|
||||
stateContext.onChange('tree-rendering')
|
||||
dispatch.set({ defer })
|
||||
|
||||
const visibleNodesGenerator = new VisibleNodesGenerator({
|
||||
root: treeRoot,
|
||||
compress: config.compressSingletonFolder,
|
||||
async getTreeData(path) {
|
||||
const { root } = await platform.getTreeData(metaData, path, false, config.accessToken)
|
||||
return root
|
||||
},
|
||||
})
|
||||
|
||||
if (!checker()) return
|
||||
dispatch.set({ visibleNodesGenerator })
|
||||
|
||||
visibleNodesGenerator.onUpdate(visibleNodes => {
|
||||
if (!checker()) return
|
||||
dispatch.set({ visibleNodes })
|
||||
})
|
||||
|
||||
if (platform.shouldExpandAll?.()) {
|
||||
const unsubscribe = visibleNodesGenerator.onUpdate(visibleNodes => {
|
||||
unsubscribe()
|
||||
visibleNodes.nodes.forEach(node => {
|
||||
if (!checker()) return
|
||||
dispatch.call(toggleNodeExpansion, node, { recursive: true })
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const targetPath = platform.getCurrentPath(metaData.branchName)
|
||||
if (targetPath && checker()) dispatch.call(goTo, targetPath)
|
||||
}
|
||||
|
||||
if (!checker()) return
|
||||
stateContext.onChange('tree-rendered')
|
||||
})
|
||||
}
|
||||
|
||||
export const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent<HTMLElement>]> =
|
||||
dispatch => event => {
|
||||
const {
|
||||
state: { searched, visibleNodes },
|
||||
} = dispatch.get()
|
||||
if (!visibleNodes) return
|
||||
const { nodes, focusedNode, expandedNodes } = visibleNodes
|
||||
function handleVerticalMove(index: number) {
|
||||
if (0 <= index && index < nodes.length) {
|
||||
DOMHelper.focusFileExplorer()
|
||||
dispatch.call(focusNode, nodes[index])
|
||||
} else {
|
||||
DOMHelper.focusSearchInput()
|
||||
dispatch.call(focusNode, null)
|
||||
}
|
||||
}
|
||||
|
||||
const { key } = event
|
||||
// prevent document body scrolling if the keypress results in Gitako action
|
||||
let muteEvent = true
|
||||
if (focusedNode) {
|
||||
const focusedNodeIndex = nodes.findIndex(node => node.path === focusedNode.path)
|
||||
switch (key) {
|
||||
case 'ArrowUp':
|
||||
// focus on previous node
|
||||
handleVerticalMove(focusedNodeIndex - 1)
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
// focus on next node
|
||||
handleVerticalMove(focusedNodeIndex + 1)
|
||||
break
|
||||
|
||||
case 'ArrowLeft':
|
||||
if (wouldBlockHistoryNavigation(event)) {
|
||||
muteEvent = false
|
||||
break
|
||||
}
|
||||
if (expandedNodes.has(focusedNode.path)) {
|
||||
dispatch.call(toggleNodeExpansion, focusedNode, { recursive: event.altKey })
|
||||
} else {
|
||||
// go forward to the start of the list, find the closest node with lower depth
|
||||
const parentNode = getVisibleParentNode(nodes, focusedNode)
|
||||
if (parentNode) {
|
||||
dispatch.call(focusNode, parentNode)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
// consider the two keys as 'confirm' key
|
||||
case 'ArrowRight':
|
||||
if (wouldBlockHistoryNavigation(event)) {
|
||||
muteEvent = false
|
||||
break
|
||||
}
|
||||
// expand node or focus on first content node or redirect to file page
|
||||
if (focusedNode.type === 'tree') {
|
||||
if (expandedNodes.has(focusedNode.path)) {
|
||||
const nextNode = nodes[focusedNodeIndex + 1]
|
||||
if (focusedNode.contents?.includes(nextNode)) {
|
||||
dispatch.call(focusNode, nextNode)
|
||||
}
|
||||
} else {
|
||||
dispatch.call(toggleNodeExpansion, focusedNode, { recursive: event.altKey })
|
||||
}
|
||||
} else if (focusedNode.type === 'blob') {
|
||||
const focusedNodeElement = DOMHelper.findNodeElement(focusedNode, event.currentTarget)
|
||||
if (focusedNodeElement && focusedNode.url)
|
||||
loadWithPJAX(focusedNode.url, focusedNodeElement)
|
||||
} else if (focusedNode.type === 'commit') {
|
||||
window.open(focusedNode.url)
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
// expand node or redirect to file page
|
||||
if (searched) {
|
||||
dispatch.call(goTo, focusedNode.path.split('/'))
|
||||
} else {
|
||||
if (focusedNode.type === 'tree') {
|
||||
dispatch.call(toggleNodeExpansion, focusedNode, { recursive: event.altKey })
|
||||
} else if (focusedNode.type === 'blob') {
|
||||
const focusedNodeElement = DOMHelper.findNodeElement(focusedNode, event.currentTarget)
|
||||
if (focusedNodeElement && focusedNode.url)
|
||||
loadWithPJAX(focusedNode.url, focusedNodeElement)
|
||||
} else if (focusedNode.type === 'commit') {
|
||||
window.open(focusedNode.url)
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
muteEvent = false
|
||||
}
|
||||
if (muteEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
} else {
|
||||
// now search input is focused
|
||||
if (nodes.length) {
|
||||
switch (key) {
|
||||
case 'ArrowDown':
|
||||
DOMHelper.focusFileExplorer()
|
||||
dispatch.call(focusNode, nodes[0])
|
||||
break
|
||||
case 'ArrowUp':
|
||||
DOMHelper.focusFileExplorer()
|
||||
dispatch.call(focusNode, nodes[nodes.length - 1])
|
||||
break
|
||||
default:
|
||||
muteEvent = false
|
||||
}
|
||||
if (muteEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const onFocusSearchBar: BoundMethodCreator = dispatch => () => dispatch.call(focusNode, null)
|
||||
|
||||
export const updateSearchKey: BoundMethodCreator<[string]> = dispatch => searchKey => {
|
||||
dispatch.set({ searchKey, searched: searchKey !== '' })
|
||||
}
|
||||
|
||||
export const goTo: BoundMethodCreator<[string[]]> = dispatch => path => {
|
||||
const {
|
||||
state: { visibleNodesGenerator },
|
||||
} = dispatch.get()
|
||||
if (!visibleNodesGenerator) return
|
||||
|
||||
dispatch.call(updateSearchKey, '')
|
||||
visibleNodesGenerator.search(null)
|
||||
visibleNodesGenerator.onNextUpdate(() => {
|
||||
dispatch.call(expandTo, path)
|
||||
})
|
||||
}
|
||||
|
||||
export const setExpand: BoundMethodCreator<[TreeNode, boolean]> =
|
||||
dispatch =>
|
||||
async (node, expand = false) => {
|
||||
const {
|
||||
state: { visibleNodesGenerator },
|
||||
} = dispatch.get()
|
||||
if (!visibleNodesGenerator) return
|
||||
|
||||
await visibleNodesGenerator.setExpand(node, expand)
|
||||
dispatch.call(focusNode, node)
|
||||
}
|
||||
|
||||
export const toggleNodeExpansion: BoundMethodCreator<
|
||||
[
|
||||
TreeNode,
|
||||
{
|
||||
recursive?: boolean
|
||||
},
|
||||
]
|
||||
> =
|
||||
dispatch =>
|
||||
async (node, { recursive = false }) => {
|
||||
const {
|
||||
state: { visibleNodesGenerator },
|
||||
} = dispatch.get()
|
||||
if (!visibleNodesGenerator) return
|
||||
|
||||
if (node.type === 'tree') {
|
||||
visibleNodesGenerator.focusNode(node)
|
||||
await visibleNodesGenerator.toggleExpand(node, recursive)
|
||||
}
|
||||
}
|
||||
|
||||
export const focusNode: BoundMethodCreator<[TreeNode | null]> =
|
||||
dispatch => (node: TreeNode | null) => {
|
||||
const {
|
||||
state: { visibleNodesGenerator },
|
||||
} = dispatch.get()
|
||||
if (!visibleNodesGenerator) return
|
||||
|
||||
visibleNodesGenerator.focusNode(node)
|
||||
}
|
||||
|
||||
export const onNodeClick: BoundMethodCreator<
|
||||
[React.MouseEvent<HTMLElement, MouseEvent>, TreeNode]
|
||||
> = dispatch => (event, node) => {
|
||||
switch (node.type) {
|
||||
case 'tree': {
|
||||
const {
|
||||
props: {
|
||||
config: { recursiveToggleFolder },
|
||||
},
|
||||
} = dispatch.get()
|
||||
const recursive =
|
||||
(recursiveToggleFolder === 'shift' && event.shiftKey) ||
|
||||
(recursiveToggleFolder === 'alt' && event.altKey)
|
||||
// recursive toggle action may conflict with browser default action
|
||||
// e.g. shift + click is the default open in new tab action on macOS
|
||||
// giving recursive toggle action higher priority than default action
|
||||
if (!recursive && isOpenInNewWindowClick(event)) return
|
||||
|
||||
event.preventDefault()
|
||||
dispatch.call(toggleNodeExpansion, node, { recursive })
|
||||
break
|
||||
}
|
||||
case 'blob': {
|
||||
if (isOpenInNewWindowClick(event)) return
|
||||
|
||||
dispatch.call(focusNode, node)
|
||||
if (node.url) {
|
||||
const isHashLink = node.url.includes('#')
|
||||
if (!isHashLink) {
|
||||
event.preventDefault()
|
||||
loadWithPJAX(node.url, event.currentTarget)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'commit': {
|
||||
// pass event, open in new tab thanks to the target="_blank" on the anchor element
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const expandTo: BoundMethodCreator<[string[]]> = dispatch => async currentPath => {
|
||||
const {
|
||||
state: { visibleNodesGenerator },
|
||||
} = dispatch.get()
|
||||
if (!visibleNodesGenerator) return
|
||||
|
||||
const nodeExpandedTo = await visibleNodesGenerator.expandTo(currentPath.join('/'))
|
||||
if (nodeExpandedTo) {
|
||||
visibleNodesGenerator.focusNode(nodeExpandedTo)
|
||||
}
|
||||
}
|
||||
|
||||
function wouldBlockHistoryNavigation(event: React.KeyboardEvent) {
|
||||
// Cmd + left/right on macOS
|
||||
// Alt + left/right on other OSes
|
||||
return (
|
||||
(os === OperatingSystems.macOS && event.metaKey) ||
|
||||
(os !== OperatingSystems.macOS && event.altKey)
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Sources } from 'driver/connect'
|
||||
import * as FileExplorer from './FileExplorer'
|
||||
import {
|
||||
ConnectorState as FileExplorerConnectorState,
|
||||
Props as FileExplorerProps,
|
||||
} from './FileExplorer'
|
||||
|
||||
export const FileExplorerCore: Sources<FileExplorerProps, FileExplorerConnectorState> = FileExplorer
|
||||
|
|
@ -9,3 +9,8 @@ export const GITEE_OAUTH = {
|
|||
}
|
||||
|
||||
export const VERSION = process.env.VERSION
|
||||
|
||||
export const SENTRY = {
|
||||
PUBLIC_KEY: process.env.SENTRY_PUBLIC_KEY,
|
||||
PROJECT_ID: process.env.SENTRY_PROJECT_ID,
|
||||
}
|
||||
|
|
|
|||
20
src/global.d.ts
vendored
20
src/global.d.ts
vendored
|
|
@ -1,12 +1,17 @@
|
|||
type AnyArray = any[] // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
type MetaData = {
|
||||
userName: string
|
||||
repoName: string
|
||||
defaultBranchName: string
|
||||
branchName: string
|
||||
type?: EnumString<'tree' | 'blob' | 'pull'>
|
||||
type?: EnumString<'tree' | 'blob' | 'pull' | 'commit'>
|
||||
}
|
||||
|
||||
type PartialMetaData = Omit<MakeOptional<MetaData, 'branchName'>, 'defaultBranchName'>
|
||||
type PartialMetaData = Omit<
|
||||
MakeOptional<MetaData, 'repoName' | 'userName' | 'branchName'>,
|
||||
'defaultBranchName'
|
||||
>
|
||||
|
||||
type TreeNode = {
|
||||
name: string
|
||||
|
|
@ -17,8 +22,8 @@ type TreeNode = {
|
|||
sha?: string
|
||||
accessDenied?: boolean
|
||||
comments?: {
|
||||
active: number,
|
||||
resolved: number,
|
||||
active: number
|
||||
resolved: number
|
||||
}
|
||||
diff?: {
|
||||
status: 'modified' | 'added' | 'removed' | 'renamed'
|
||||
|
|
@ -43,3 +48,10 @@ type VoidFN<T> = (payload: T) => void
|
|||
|
||||
type Async<T> = T | Promise<T>
|
||||
type EnumString<S extends string> = S | (string & {})
|
||||
|
||||
type JSONPrimitive = string | number | boolean | null | undefined
|
||||
type JSONObject = {
|
||||
[key: string]: JSONValue
|
||||
}
|
||||
type JSONArray = JSONValue[]
|
||||
type JSONValue = JSONPrimitive | JSONObject | JSONArray
|
||||
|
|
|
|||
|
|
@ -1,25 +1,41 @@
|
|||
import { errors } from 'platforms'
|
||||
import { isEnterprise } from '.'
|
||||
import { is } from '../../utils/is'
|
||||
import { gitakoServiceHost } from '../../utils/networkService'
|
||||
import { continuousLoadPages, getDOM, resolveHeaderLink } from './utils'
|
||||
|
||||
function apiRateLimitExceeded(content: any /* safe any */) {
|
||||
return content?.['documentation_url'] === 'https://developer.github.com/v3/#rate-limiting'
|
||||
function isAPIRateLimitExceeded(content: JSONValue) {
|
||||
return (
|
||||
is.JSON.object(content) &&
|
||||
content?.['documentation_url'] === 'https://developer.github.com/v3/#rate-limiting'
|
||||
)
|
||||
}
|
||||
|
||||
function isEmptyProject(content: any /* safe any */) {
|
||||
return content?.['message'] === 'Git Repository is empty.'
|
||||
function isEmptyProject(content: JSONValue) {
|
||||
return is.JSON.object(content) && content?.['message'] === 'Git Repository is empty.'
|
||||
}
|
||||
|
||||
function isBlockedProject(content: any /* safe any */) {
|
||||
return content?.['message'] === 'Repository access blocked'
|
||||
function isBlockedProject(content: JSONValue) {
|
||||
return is.JSON.object(content) && content?.['message'] === 'Repository access blocked'
|
||||
}
|
||||
|
||||
async function request(
|
||||
export const responseBodyResolvers = {
|
||||
asIs: (response: Response) => response,
|
||||
json(response: Response) {
|
||||
const contentType = response.headers.get('Content-Type') || response.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) return response.json()
|
||||
throw new Error(`Response content type is "${contentType}"`)
|
||||
},
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
{
|
||||
accessToken,
|
||||
}: {
|
||||
accessToken?: string
|
||||
} = {},
|
||||
bodyResolver: (response: Response) => Async<T> = responseBodyResolvers.json,
|
||||
) {
|
||||
const headers = {} as HeadersInit & {
|
||||
Authorization?: string
|
||||
|
|
@ -35,38 +51,35 @@ async function request(
|
|||
throw new Error(errors.CONNECTION_BLOCKED)
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('Content-Type') || res.headers.get('content-type')
|
||||
const isJson = contentType?.includes('application/json')
|
||||
// About res.ok:
|
||||
// True if res.status between 200~299
|
||||
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Response/ok
|
||||
if (res.ok) {
|
||||
if (isJson) return res.json()
|
||||
throw new Error(`Response content type is "${contentType}"`)
|
||||
} else {
|
||||
if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND)
|
||||
else if (res.status === 403) throw new Error(errors.API_RATE_LIMIT)
|
||||
else if (res.status === 500) throw new Error(errors.SERVER_FAULT)
|
||||
else if (isJson) {
|
||||
const content = await res.json()
|
||||
if (apiRateLimitExceeded(content)) throw new Error(errors.API_RATE_LIMIT)
|
||||
if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT)
|
||||
if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT)
|
||||
throw new Error(`Unknown message content "${content?.message}"`)
|
||||
} else {
|
||||
throw new Error(`Response content type is "${contentType}"`)
|
||||
}
|
||||
}
|
||||
if (res.ok) return bodyResolver(res)
|
||||
|
||||
if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND)
|
||||
if (res.status === 403) throw new Error(errors.API_RATE_LIMIT)
|
||||
if (res.status === 500) throw new Error(errors.SERVER_FAULT)
|
||||
|
||||
const content = await responseBodyResolvers.json(res)
|
||||
if (isAPIRateLimitExceeded(content)) throw new Error(errors.API_RATE_LIMIT)
|
||||
if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT)
|
||||
if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT)
|
||||
|
||||
const message =
|
||||
typeof content === 'object'
|
||||
? (content as Record<string, unknown> | null)?.message
|
||||
: `${content}`
|
||||
throw new Error(`Unknown message content "${message}"`)
|
||||
}
|
||||
|
||||
const API_ENDPOINT = isEnterprise() ? `${window.location.host}/api/v3` : 'api.github.com'
|
||||
const API_ENDPOINT = isEnterprise() ? `${window.location.origin}/api/v3` : 'https://api.github.com'
|
||||
|
||||
export async function getRepoMeta(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
accessToken?: string,
|
||||
): Promise<GitHubAPI.MetaData> {
|
||||
const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}`
|
||||
const url = `${API_ENDPOINT}/repos/${userName}/${repoName}`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
|
|
@ -79,31 +92,42 @@ export async function getTreeData(
|
|||
): Promise<GitHubAPI.TreeData> {
|
||||
const search = new URLSearchParams()
|
||||
if (recursive) search.set('recursive', '1')
|
||||
const url =
|
||||
`https://${API_ENDPOINT}/repos/${userName}/${repoName}/git/trees/${branchName}?` + search
|
||||
const url = `${API_ENDPOINT}/repos/${userName}/${repoName}/git/trees/${branchName}?${search}`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
export async function getPullData(
|
||||
export async function getPullRequest(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
pullId: string,
|
||||
accessToken?: string,
|
||||
): Promise<GitHubAPI.PullData> {
|
||||
const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}/pulls/${pullId}`
|
||||
const url = `${API_ENDPOINT}/repos/${userName}/${repoName}/pulls/${pullId}`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
export async function requestPullTreeData(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
pullId: string,
|
||||
page: number,
|
||||
pageSize?: number,
|
||||
accessToken?: string,
|
||||
) {
|
||||
const search = new URLSearchParams({ page: page.toString(), per_page: `${pageSize}` })
|
||||
const url = `${API_ENDPOINT}/repos/${userName}/${repoName}/pulls/${pullId}/files?${search}`
|
||||
return await request(url, { accessToken }, responseBodyResolvers.asIs)
|
||||
}
|
||||
|
||||
export async function getPullTreeData(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
pullId: string,
|
||||
page: number,
|
||||
pageSize?: number,
|
||||
accessToken?: string,
|
||||
): Promise<GitHubAPI.PullTreeData> {
|
||||
const search = new URLSearchParams({ page: page.toString() })
|
||||
const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}/pulls/${pullId}/files?${search}`
|
||||
return await request(url, { accessToken })
|
||||
return (await requestPullTreeData(userName, repoName, pullId, page, pageSize, accessToken)).json()
|
||||
}
|
||||
|
||||
export async function getPullComments(
|
||||
|
|
@ -112,51 +136,29 @@ export async function getPullComments(
|
|||
pullId: string,
|
||||
accessToken?: string,
|
||||
): Promise<GitHubAPI.PullComments> {
|
||||
const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}/pulls/${pullId}/comments`
|
||||
const url = `${API_ENDPOINT}/repos/${userName}/${repoName}/pulls/${pullId}/comments`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
export async function getPullPageDocuments(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
pullId: string, // not used
|
||||
pullId: string,
|
||||
document?: Document,
|
||||
): Promise<Document[]> {
|
||||
// Response of this API contains view of few files but is not complete.
|
||||
const filesDOM = await getDOM(
|
||||
`https://${window.location.host}/${userName}/${repoName}/pull/${pullId}/files?_pjax=%23js-repo-pjax-container`,
|
||||
return continuousLoadPages(
|
||||
document ||
|
||||
(await getDOM(`${window.location.origin}/${userName}/${repoName}/pull/${pullId}/files`)),
|
||||
)
|
||||
const hookElement: HTMLDivElement | null = filesDOM.querySelector('div.js-pull-refresh-on-pjax')
|
||||
const hookSearchParams = new URLSearchParams(hookElement?.dataset.url)
|
||||
const [baseSHA, headSHA] = [
|
||||
hookSearchParams.get('start_commit_oid'),
|
||||
hookSearchParams.get('end_commit_oid'),
|
||||
]
|
||||
if (!baseSHA || !headSHA) throw new Error(`Cannot fetch SHA for comparison`)
|
||||
|
||||
// The SHA used to be retrieved from DOM of the pull page, but they can be unreliable if the PR has conflicts
|
||||
let search = new URLSearchParams(window.location.search)
|
||||
search.set('lines', '0')
|
||||
search.set('sha1', baseSHA)
|
||||
search.set('sha2', headSHA)
|
||||
const diffsDOMs: Document[] = []
|
||||
while (true) {
|
||||
const diffsDOM = await getDOM(
|
||||
`https://${window.location.host}/${userName}/${repoName}/diffs?${search}`,
|
||||
)
|
||||
diffsDOMs.push(diffsDOM)
|
||||
|
||||
const src = diffsDOM
|
||||
.querySelector('.js-diff-progressive-container include-fragment')
|
||||
?.getAttribute('src')
|
||||
if (!src) break
|
||||
|
||||
search = new URL(src, window.location.origin).searchParams
|
||||
}
|
||||
return diffsDOMs
|
||||
}
|
||||
|
||||
async function getDOM(url: string) {
|
||||
return new DOMParser().parseFromString(await (await fetch(url)).text(), 'text/html')
|
||||
export async function getCommitPageDocuments(): Promise<Document[]> {
|
||||
/* userName: string,
|
||||
repoName: string,
|
||||
commitId: string, */
|
||||
// arguments are not used because info are collected from DOM directly
|
||||
return continuousLoadPages(document)
|
||||
}
|
||||
|
||||
export async function getBlobData(
|
||||
|
|
@ -165,14 +167,14 @@ export async function getBlobData(
|
|||
sha: string,
|
||||
accessToken?: string,
|
||||
): Promise<GitHubAPI.BlobData> {
|
||||
const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}/git/blobs/${sha}`
|
||||
const url = `${API_ENDPOINT}/repos/${userName}/${repoName}/git/blobs/${sha}`
|
||||
return await request(url, { accessToken })
|
||||
}
|
||||
|
||||
export async function OAuth(code: string): Promise<string | null> {
|
||||
try {
|
||||
const endpoint = `https://gitako.enix.one/oauth/github?`
|
||||
const res = await fetch(endpoint + new URLSearchParams({ code }).toString(), {
|
||||
const endpoint = `https://${gitakoServiceHost}/oauth/github?${new URLSearchParams({ code })}`
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'post',
|
||||
})
|
||||
if (res.ok) {
|
||||
|
|
@ -185,3 +187,55 @@ export async function OAuth(code: string): Promise<string | null> {
|
|||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestCommitTreeData(
|
||||
userName: string,
|
||||
repoName: string,
|
||||
sha: string,
|
||||
page = 1,
|
||||
accessToken?: string,
|
||||
): Promise<Response> {
|
||||
const search = new URLSearchParams({
|
||||
per_page: '100',
|
||||
page: `${page}`,
|
||||
})
|
||||
const url = `${API_ENDPOINT}/repos/${userName}/${repoName}/commits/${sha}?${search}`
|
||||
return await request(url, { accessToken }, responseBodyResolvers.asIs)
|
||||
}
|
||||
|
||||
export async function getPaginatedData<T>(sendRequest: (page: number) => Promise<Response>) {
|
||||
const responses: Response[] = []
|
||||
let page = 1
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const response = await sendRequest(page)
|
||||
responses.push(response)
|
||||
|
||||
const headerLink = response.headers.get('link')
|
||||
if (headerLink) {
|
||||
const rels = resolveHeaderLink(headerLink)
|
||||
if (rels) {
|
||||
if (rels.position === 'first') {
|
||||
page++
|
||||
} else if (rels.position === 'middle') {
|
||||
const searchOfLast = new URL(rels.last).searchParams
|
||||
if (`${page}` === searchOfLast.get('page')) {
|
||||
// this should not actually happen because GitHub responds `prev` and `first` for the first page
|
||||
break
|
||||
}
|
||||
page++
|
||||
} else {
|
||||
// i.e. rels.position === 'last'
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// unexpected link header content
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// no link headers if there is <100 files
|
||||
break
|
||||
}
|
||||
}
|
||||
return Promise.all(responses.map(responseBodyResolvers.json)) as Promise<T[]>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { cx } from 'utils/cx'
|
|||
import { copyElementContent } from 'utils/DOMHelper'
|
||||
import { getCodeElement } from './DOMHelper'
|
||||
|
||||
type Props = {}
|
||||
|
||||
const className = 'gitako-copy-file-button'
|
||||
export const copyFileButtonClassName = className
|
||||
|
||||
|
|
@ -13,7 +11,8 @@ const contents = {
|
|||
error: 'Copy failed!',
|
||||
normal: 'Copy file',
|
||||
}
|
||||
export function CopyFileButton(props: React.PropsWithChildren<Props>) {
|
||||
|
||||
export function CopyFileButton() {
|
||||
const [content, setContent] = React.useState(contents.normal)
|
||||
React.useEffect(() => {
|
||||
if (content !== contents.normal) {
|
||||
|
|
@ -31,7 +30,7 @@ export function CopyFileButton(props: React.PropsWithChildren<Props>) {
|
|||
// onClick on <a /> won't work when rendered with `renderReact`
|
||||
const element = elementRef.current
|
||||
if (element) {
|
||||
function copyCode() {
|
||||
const copyCode = () => {
|
||||
const codeElement = getCodeElement()
|
||||
if (codeElement) {
|
||||
setContent(copyElementContent(codeElement, true) ? contents.success : contents.error)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import { Clippy, ClippyClassName } from 'components/Clippy'
|
||||
import * as React from 'react'
|
||||
import { $ } from 'utils/DOMHelper'
|
||||
import { $, formatClass, parseIntFromElement } from 'utils/DOMHelper'
|
||||
import { renderReact, run } from 'utils/general'
|
||||
import { CopyFileButton, copyFileButtonClassName } from './CopyFileButton'
|
||||
|
||||
|
|
@ -31,11 +31,20 @@ export function isInCodePage() {
|
|||
return Boolean($(branchListSelector, e => e.offsetWidth > 0 && e.offsetHeight > 0))
|
||||
}
|
||||
|
||||
export function isInPullFilesPage() {
|
||||
return $('.tabnav-tab.selected #files_tab_counter')
|
||||
}
|
||||
|
||||
export function getIssueTitle() {
|
||||
const title = $('.gh-header-title')?.textContent
|
||||
return title?.trim().replace(/\n/g, '')
|
||||
}
|
||||
|
||||
export function getCommitTitle() {
|
||||
const title = $('.commit-title')?.textContent
|
||||
return title?.trim().replace(/\n/g, '')
|
||||
}
|
||||
|
||||
export function getCurrentBranch(passive = false) {
|
||||
const selectedBranchButtonSelector = [
|
||||
'main #branch-select-menu summary',
|
||||
|
|
@ -54,9 +63,8 @@ export function getCurrentBranch(passive = false) {
|
|||
if (title !== defaultTitle && !title.includes(' ')) return title
|
||||
}
|
||||
|
||||
const findFileButtonSelector =
|
||||
'main .file-navigation a[data-hotkey="t"]'
|
||||
const urlFromFindFileButton: string | null = $(
|
||||
const findFileButtonSelector = 'main .file-navigation a[data-hotkey="t"]'
|
||||
const urlFromFindFileButton = $(
|
||||
findFileButtonSelector,
|
||||
element => (element as HTMLAnchorElement).href,
|
||||
)
|
||||
|
|
@ -64,7 +72,7 @@ export function getCurrentBranch(passive = false) {
|
|||
const commitPathRegex = /^(.*?)\/(.*?)\/find\/(.*?)$/
|
||||
const result = urlFromFindFileButton.match(commitPathRegex)
|
||||
if (result) {
|
||||
const [_, userName, repoName, branchName] = result
|
||||
const [_, userName, repoName, branchName] = result // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
if (!branchName.includes(' ')) return branchName
|
||||
}
|
||||
}
|
||||
|
|
@ -142,7 +150,7 @@ export function getCodeElement() {
|
|||
*/
|
||||
export function attachCopyFileBtn() {
|
||||
const removeButtons = () => {
|
||||
const buttons = document.querySelectorAll(`.${copyFileButtonClassName}`)
|
||||
const buttons = document.querySelectorAll(formatClass(copyFileButtonClassName))
|
||||
buttons.forEach(button => {
|
||||
button.parentElement?.removeChild(button)
|
||||
})
|
||||
|
|
@ -214,7 +222,7 @@ export function attachCopySnippet() {
|
|||
}
|
||||
}
|
||||
function removeAttachedOnes() {
|
||||
const buttons = document.querySelectorAll(`.${ClippyClassName}`)
|
||||
const buttons = document.querySelectorAll(formatClass(ClippyClassName))
|
||||
buttons.forEach(button => {
|
||||
button.parentElement?.removeChild(button)
|
||||
})
|
||||
|
|
@ -262,9 +270,27 @@ export function getPath() {
|
|||
}
|
||||
|
||||
export function isNativePRFileTreeShown() {
|
||||
return $('file-tree[data-target="diff-layout.fileTree"]')
|
||||
return $('file-tree[data-target="diff-layout.fileTree"]', ele => {
|
||||
// It would be set `display: hidden;` when collapsed
|
||||
const { width, height } = ele.getBoundingClientRect()
|
||||
return width * height > 0
|
||||
})
|
||||
}
|
||||
|
||||
export function selectEnterpriseStatHeader() {
|
||||
return $('.stats-ui-enabled .server-stats')
|
||||
}
|
||||
|
||||
export function getPullRequestFilesCount() {
|
||||
return $('#files_tab_counter', parseIntFromElement)
|
||||
}
|
||||
|
||||
export function getPRDiffTotalStat() {
|
||||
const [added, removed] = [$('#diffstat .color-fg-success'), $('#diffstat .color-fg-danger')].map(
|
||||
e => (e ? parseIntFromElement(e) : null),
|
||||
)
|
||||
return {
|
||||
added,
|
||||
removed,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
85
src/platforms/GitHub/Request.d.ts
vendored
85
src/platforms/GitHub/Request.d.ts
vendored
|
|
@ -15,19 +15,98 @@ declare namespace GitHubAPI {
|
|||
url: string
|
||||
}
|
||||
|
||||
type PullTreeItem = {
|
||||
type User = {
|
||||
login: string
|
||||
id: number
|
||||
node_id: string
|
||||
avatar_url: string
|
||||
gravatar_id: string
|
||||
url: string
|
||||
html_url: string
|
||||
followers_url: string
|
||||
following_url: string
|
||||
gists_url: string
|
||||
starred_url: string
|
||||
subscriptions_url: string
|
||||
organizations_url: string
|
||||
repos_url: string
|
||||
events_url: string
|
||||
received_events_url: string
|
||||
type: 'User' | string
|
||||
site_admin: boolean
|
||||
}
|
||||
|
||||
type Commit = {
|
||||
url: string
|
||||
author: {
|
||||
name: string
|
||||
email: string
|
||||
date: string
|
||||
}
|
||||
committer: {
|
||||
name: string
|
||||
email: string
|
||||
date: string
|
||||
}
|
||||
message: string
|
||||
tree: {
|
||||
url: string
|
||||
sha: string
|
||||
}
|
||||
comment_count: number
|
||||
verification: {
|
||||
verified: boolean
|
||||
reason: 'unsigned' | string
|
||||
signature: null
|
||||
payload: null
|
||||
}
|
||||
}
|
||||
|
||||
type CommitResponseData = {
|
||||
url: string
|
||||
sha: string
|
||||
node_id: string
|
||||
html_url: string
|
||||
comments_url: string
|
||||
commit: Commit
|
||||
author: User
|
||||
committer: User
|
||||
parents: {
|
||||
url: string
|
||||
sha: string
|
||||
}[]
|
||||
stats: {
|
||||
additions: number
|
||||
deletions: number
|
||||
total: number
|
||||
}
|
||||
files: CommitTreeItem[]
|
||||
}
|
||||
|
||||
type CommitTreeItem = {
|
||||
additions: number
|
||||
blob_url: string
|
||||
changes: number
|
||||
contents_url: string
|
||||
deletions: number
|
||||
filename: string
|
||||
patch: string
|
||||
raw_url: string
|
||||
sha: string
|
||||
status: 'modified' | 'added' | 'removed' | 'renamed'
|
||||
}
|
||||
|
||||
type PullTreeItem = {
|
||||
additions: number
|
||||
blob_url: string
|
||||
changes: number
|
||||
deletions: number
|
||||
filename: string
|
||||
patch: string
|
||||
raw_url: string
|
||||
status: 'modified' | 'added' | 'removed' | 'renamed'
|
||||
sha: string
|
||||
contents_url: string
|
||||
}
|
||||
|
||||
type PullData = {
|
||||
state: 'open' | 'closed'
|
||||
title: string
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue