Merge branch 'next' into develop

This commit is contained in:
EnixCoda 2022-07-17 01:51:35 +08:00
commit feb2fb491a
165 changed files with 6019 additions and 8321 deletions

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn lint-staged --quiet

View file

@ -1,3 +1,4 @@
*-profile/
dist/
vscode-icons/
Safari

View file

@ -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)

View file

@ -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')

View file

@ -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)

View file

@ -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()
})

View file

@ -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'))

View file

@ -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'))

View file

@ -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 }

View file

@ -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: {},

View file

@ -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
View 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()

View 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)
}
}

View 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)
}
}

View file

@ -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()

View file

@ -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
View 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>>
}

View file

@ -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'}>

View file

@ -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}>

View file

@ -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>
)
}

View file

@ -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>
)

View file

@ -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']
}) {

View 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>
)
}

View file

@ -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} />
)}

View 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}
/>
)
})

View 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],
)
}

View file

@ -0,0 +1,8 @@
import * as React from 'react'
import * as DOMHelper from 'utils/DOMHelper'
export function useFocusFileExplorerOnFirstRender() {
React.useEffect(() => {
DOMHelper.focusFileExplorer()
}, [])
}

View 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],
)
}

View file

@ -0,0 +1,6 @@
import { platform } from 'platforms'
import { useCallback } from 'react'
export function useGetCurrentPath({ branchName }: MetaData) {
return useCallback(() => platform.getCurrentPath(branchName), [branchName])
}

View 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],
)
}

View 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],
)
}

View 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} />
&nbsp;
{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],
)
}

View 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],
)
}

View 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],
)
}

View 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],
)
}

View 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],
)
}

View file

@ -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
}

View file

@ -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>

View 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>
)
}

View 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],
)
}

View 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])
}

View 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,
}
}

View 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
View 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>
)
}

View file

@ -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>
)
}

View 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', /..../)

View file

@ -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
}

View 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,
)
}

View file

@ -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>,
])}
</>
)
}

View file

@ -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()}</>
}

View file

@ -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>
)
})

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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>

View file

@ -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>
</>
)
}

View file

@ -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>
)
}

View 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,
}}
/>
)
}

View file

@ -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>
}
/>
)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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>
)
}

View 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}
/>
)
}

View file

@ -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
View file

@ -0,0 +1,2 @@
export type Size = number
export type Size2D = [Size, Size]

View file

@ -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))
}

View file

@ -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>
)
}

View file

@ -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,

View file

@ -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

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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',

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View 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}
/>
)
}

View 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>
)
)}
</>
)
}

View 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}
/>
)
}

View 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],
),
}
}

View file

@ -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`)

View file

@ -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)
}

View file

@ -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>
}

View file

@ -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
}

View 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>
)
}

View file

@ -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
}

View file

@ -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}>

View 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
}

View file

@ -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>
)
}

View file

@ -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

View file

@ -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

View file

@ -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)
)
}

View file

@ -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

View file

@ -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
View file

@ -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

View file

@ -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[]>
}

View file

@ -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)

View file

@ -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,
}
}

View file

@ -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