Merge branch 'develop' into manifest-v3

# Conflicts:
#	src/manifest.json
This commit is contained in:
EnixCoda 2022-12-10 21:39:32 +08:00
commit 33fd667e1f
203 changed files with 9520 additions and 10553 deletions

View file

@ -1,25 +0,0 @@
{
"env": {
"test": {
"plugins": ["babel-plugin-transform-es2015-modules-commonjs"]
}
},
"presets": [
[
"@babel/preset-env",
{
"modules": false,
"targets": {
"esmodules": true
},
"exclude": [
"@babel/plugin-transform-async-to-generator",
"@babel/plugin-proposal-object-rest-spread"
]
}
],
"@babel/preset-typescript",
"@babel/preset-react"
],
"plugins": ["@babel/plugin-proposal-optional-chaining", "@babel/plugin-proposal-class-properties"]
}

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

18
.eslintrc.json Normal file
View file

@ -0,0 +1,18 @@
{
"root": true,
"env": {
"es2022": true
},
"overrides": [
{
"files": ["*.tsx?"],
"excludedFiles": ["*.d.ts"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
}
}
]
}

View file

@ -1,29 +1,13 @@
name: CI
name: Generate build for reuse
on:
push:
branches:
- '**'
paths-ignore:
- 'server/**'
pull_request:
branches:
- '**'
paths-ignore:
- 'server/**'
workflow_call:
jobs:
build-and-test:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Retrieve vscode icons
uses: actions/checkout@v2
with:
repository: 'vscode-icons/vscode-icons'
path: 'vscode-icons'
- uses: actions/checkout@v3
- name: Get yarn cache directory path
id: yarn-cache-dir-path
@ -38,19 +22,24 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- name: Install deps
- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: |
yarn
- name: Retrieve vscode icons
uses: actions/checkout@v3
with:
repository: 'vscode-icons/vscode-icons'
path: 'vscode-icons'
- name: Build
run: |
make build
- name: Test
uses: mujo-code/puppeteer-headful@master
env:
CI: 'true'
- name: Archive production artifacts
uses: actions/upload-artifact@v3
with:
args: yarn test
name: dist
path: dist

142
.github/workflows/dependabot.yml vendored Normal file
View file

@ -0,0 +1,142 @@
# Source: https://github.com/hrvey/combine-prs-workflow
name: 'Combine dependabot PRs'
# Controls when the action will run - in this case triggered manually
on:
workflow_dispatch:
inputs:
branchPrefix:
description: 'Branch prefix to find combinable PRs based on'
required: true
default: 'dependabot'
mustBeGreen:
description: 'Only combine PRs that are green (status is success)'
required: true
default: false
combineBranchName:
description: 'Name of the branch to combine PRs into'
required: true
default: 'dependabot-merged'
ignoreLabel:
description: 'Exclude PRs with this label'
required: true
default: 'no-combine'
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "combine-prs"
combine-prs:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- uses: actions/github-script@v3
id: fetch-branch-names
name: Fetch branch names
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', {
owner: context.repo.owner,
repo: context.repo.repo
});
branches = [];
prs = [];
base_branch = null;
for (const pull of pulls) {
const branch = pull['head']['ref'];
console.log('Pull for branch: ' + branch);
if (branch.startsWith('${{ github.event.inputs.branchPrefix }}')) {
console.log('Branch matched: ' + branch);
statusOK = true;
if(${{ github.event.inputs.mustBeGreen }}) {
console.log('Checking green status: ' + branch);
const statuses = await github.paginate('GET /repos/{owner}/{repo}/commits/{ref}/status', {
owner: context.repo.owner,
repo: context.repo.repo,
ref: branch
});
if(statuses.length > 0) {
const latest_status = statuses[0]['state'];
console.log('Validating status: ' + latest_status);
if(latest_status != 'success') {
console.log('Discarding ' + branch + ' with status ' + latest_status);
statusOK = false;
}
}
}
console.log('Checking labels: ' + branch);
const labels = pull['labels'];
for(const label of labels) {
const labelName = label['name'];
console.log('Checking label: ' + labelName);
if(labelName == '${{ github.event.inputs.ignoreLabel }}') {
console.log('Discarding ' + branch + ' with label ' + labelName);
statusOK = false;
}
}
if (statusOK) {
console.log('Adding branch to array: ' + branch);
branches.push(branch);
prs.push('#' + pull['number'] + ' ' + pull['title']);
base_branch = pull['base']['ref'];
}
}
}
if (branches.length == 0) {
core.setFailed('No PRs/branches matched criteria');
return;
}
core.setOutput('base-branch', base_branch);
core.setOutput('prs-string', prs.join('\n'));
combined = branches.join(' ')
console.log('Combined: ' + combined);
return combined
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2.3.3
with:
fetch-depth: 0
# Creates a branch with other PR branches merged together
- name: Created combined branch
env:
BASE_BRANCH: ${{ steps.fetch-branch-names.outputs.base-branch }}
BRANCHES_TO_COMBINE: ${{ steps.fetch-branch-names.outputs.result }}
COMBINE_BRANCH_NAME: ${{ github.event.inputs.combineBranchName }}
run: |
echo "$BRANCHES_TO_COMBINE"
sourcebranches="${BRANCHES_TO_COMBINE%\"}"
sourcebranches="${sourcebranches#\"}"
basebranch="${BASE_BRANCH%\"}"
basebranch="${basebranch#\"}"
git config pull.rebase false
git config user.name github-actions
git config user.email github-actions@github.com
git branch $COMBINE_BRANCH_NAME $basebranch
git checkout $COMBINE_BRANCH_NAME
git pull origin $sourcebranches --no-edit
git push origin $COMBINE_BRANCH_NAME
# Creates a PR with the new combined branch
- uses: actions/github-script@v3
name: Create Combined Pull Request
env:
PRS_STRING: ${{ steps.fetch-branch-names.outputs.prs-string }}
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const prString = process.env.PRS_STRING;
const body = 'This PR was created by the Combine PRs action by combining the following PRs:\n' + prString;
await github.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Combined PR',
head: '${{ github.event.inputs.combineBranchName }}',
base: '${{ steps.fetch-branch-names.outputs.base-branch }}',
body: body
});

View file

@ -7,39 +7,27 @@ on:
- release-*
jobs:
build:
uses: ./.github/workflows/build.yml
version:
uses: ./.github/workflows/version.yml
release:
needs:
- build
- version
runs-on: ubuntu-latest
steps:
- name: Get the ref
id: get_ref
run: echo ::set-output name=VERSION::$(echo $GITHUB_REF | cut -d / -f 3)
- uses: actions/checkout@v3
- uses: actions/checkout@v1
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Cache deps
uses: actions/cache@v1
id: yarn-cache
- name: Download a built dist artifact
uses: actions/download-artifact@v3
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Retrieve vscode icons
uses: actions/checkout@v2
with:
repository: 'vscode-icons/vscode-icons'
path: 'vscode-icons'
- name: Build
run: |
yarn
make build
name: dist
path: dist
- name: Create Release
id: create_release
@ -47,8 +35,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.get_ref.outputs.VERSION }}
release_name: ${{ steps.get_ref.outputs.VERSION }}
tag_name: ${{ needs.version.outputs.VERSION }}
release_name: ${{ needs.version.outputs.VERSION }}
draft: false
prerelease: false

96
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,96 @@
name: Tests
on:
push:
branches:
- '**'
paths-ignore:
- 'server/**'
pull_request:
branches:
- '**'
paths-ignore:
- 'server/**'
# Runs everyday to detect GitHub update in time
schedule:
- cron: '0 0 * * *'
jobs:
build:
uses: ./.github/workflows/build.yml
e2e-test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Download a built dist artifact
uses: actions/download-artifact@v3
with:
name: dist
path: dist
# Found no way to reuse cache steps
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Cache deps
uses: actions/cache@v1
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: |
yarn
- name: E2E Test
uses: mujo-code/puppeteer-headful@master
env:
CI: 'true'
with:
args: yarn test
unit-test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Download a built dist artifact
uses: actions/download-artifact@v3
with:
name: dist
# Found no way to reuse cache steps
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Cache deps
uses: actions/cache@v1
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: |
yarn
- name: Unit Test
run: |
yarn jest src

19
.github/workflows/version.yml vendored Normal file
View file

@ -0,0 +1,19 @@
name: Get VERSION for referencing
on:
workflow_call:
outputs:
VERSION:
description: "The VERSION string"
value: ${{ jobs.version.outputs.VERSION }}
jobs:
version:
runs-on: ubuntu-latest
outputs:
VERSION: ${{ steps.get_ref.outputs.VERSION }}
steps:
- name: Get the ref
id: get_ref
run: echo ::set-output name=VERSION::$(echo $GITHUB_REF | cut -d / -f 3)

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ tmp
dist
yarn-error.log
vscode-icons
firefox-profile

4
.husky/pre-commit Executable file
View file

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

4
.prettierignore Normal file
View file

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

View file

@ -9,6 +9,9 @@ update-icons:
node scripts/resolve-languages-map
node scripts/generate-icon-index
version-safari:
sed -i '' -E 's/MARKETING_VERSION = .*;/MARKETING_VERSION = $(RAW_VERSION);/' Safari/Gitako/Gitako.xcodeproj/project.pbxproj
build:
rm -rf dist
yarn build
@ -45,6 +48,7 @@ release:
$(MAKE) compress-source
$(MAKE) compress-env
$(MAKE) compress-icons-into-source-for-mz-review
$(MAKE) copy-build-safari
compress-source:
git archive -o dist/source-$(FULL_VERSION).zip HEAD

View file

@ -481,6 +481,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -542,6 +543,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -565,10 +567,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Gitako Extension/Gitako_Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3.6.1;
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KVT97368XL;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = "Gitako Extension/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -576,9 +579,10 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 3.6.1;
MARKETING_VERSION = 3.9.0;
PRODUCT_BUNDLE_IDENTIFIER = enixcoda.Gitako.Extension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
};
@ -588,10 +592,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Gitako Extension/Gitako_Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3.6.1;
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KVT97368XL;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = "Gitako Extension/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -599,9 +604,10 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 3.6.1;
MARKETING_VERSION = 3.9.0;
PRODUCT_BUNDLE_IDENTIFIER = enixcoda.Gitako.Extension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
};
@ -614,8 +620,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Gitako/Gitako.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KVT97368XL;
@ -625,9 +631,10 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 3.6.1;
MARKETING_VERSION = 3.9.0;
PRODUCT_BUNDLE_IDENTIFIER = enixcoda.Gitako;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "Developer Sign for Distribution";
SWIFT_VERSION = 5.0;
};
name = Debug;
@ -639,8 +646,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Gitako/Gitako.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KVT97368XL;
@ -650,9 +657,10 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 3.6.1;
MARKETING_VERSION = 3.9.0;
PRODUCT_BUNDLE_IDENTIFIER = enixcoda.Gitako;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "Developer Sign for Distribution";
SWIFT_VERSION = 5.0;
};
name = Release;

View file

@ -1,16 +1,19 @@
{
"images" : [
{
"filename" : "Gitako-16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "Gitako-32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "Gitako-33.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
@ -28,6 +31,7 @@
"size" : "128x128"
},
{
"filename" : "Gitako-257.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
@ -39,16 +43,19 @@
"size" : "256x256"
},
{
"filename" : "Gitako-513.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "Gitako-512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "Gitako-1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(MARKETING_VERSION)</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>LSMinimumSystemVersion</key>

12
__tests__/.eslintrc.json Normal file
View file

@ -0,0 +1,12 @@
{
"env": {
"jest": true
},
"overrides": [
{
"files": ["*.ts"],
"excludedFiles": ["*.d.ts"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
}
]
}

View file

@ -0,0 +1,2 @@
// TS files cannot be transformed without this babel config
module.exports = require('../babel.config')

View file

@ -1,4 +1,6 @@
import { expectToFind, expectToNotFind, sleep, waitForLegacyPJAXRedirect } from '../../utils'
import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/commits/develop'))
@ -6,11 +8,11 @@ describe(`in Gitako project page`, () => {
it('should not break go back in history', async () => {
for (let i = 0; i < 3; i++) {
const commitLinks = await page.$$(
`#js-repo-pjax-container .TimelineItem-body ol li > div:nth-child(1) a[href*="/commit/"]`,
`main .TimelineItem-body ol li > div:nth-child(1) a[href*="/commit/"]`,
)
if (commitLinks.length < 2) throw new Error(`No enough commits`)
commitLinks[i].click()
await waitForLegacyPJAXRedirect()
await waitForRedirect()
await expectToFind('div.commit')
await sleep(1000)

View file

@ -1,4 +1,6 @@
import { expectToFind, expectToNotFind, sleep, waitForLegacyPJAXRedirect } from '../../utils'
import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/tree/develop/src'))
@ -9,7 +11,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

@ -4,9 +4,11 @@ import {
patientClick,
selectFileTreeItem,
sleep,
waitForLegacyPJAXRedirect
waitForRedirect,
} from '../../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/tree/develop/src'))
@ -15,14 +17,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

@ -5,23 +5,25 @@ import {
patientClick,
selectFileTreeItem,
sleep,
waitForLegacyPJAXRedirect
waitForRedirect,
} from '../../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako'))
beforeAll(() => page.goto('https://github.com/EnixCoda/Gitako/tree/test/multiple-changes'))
it('should work with PJAX', async () => {
await sleep(3000)
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

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

@ -144,7 +144,7 @@ module.exports = {
testMatch: ['**/?(*.)+(spec|test).ts?(x)'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/', '.d.ts$', '<rootDir>/vscode-icons/'],
testPathIgnorePatterns: ['/node_modules/', '.d.ts$'],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
@ -158,7 +158,7 @@ module.exports = {
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
testTimeout: 20000,
testTimeout: 30 * 1000,
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",

View file

@ -12,38 +12,36 @@ export function sleep(timeout: number) {
export async function scroll({
totalDistance,
step = 1,
duration = 500,
stepDistance = 100,
}: {
totalDistance: number
step?: number
duration?: number
stepDistance?: number
}) {
let distance = 0
while ((distance += step) < totalDistance) {
await (page.mouse as any).wheel({ deltaY: step })
await sleep((duration * step) / totalDistance)
while ((distance += stepDistance) < totalDistance) {
await page.mouse.wheel({ deltaY: stepDistance })
}
}
export function assert(condition: boolean, err?: Error | string) {
export function assert(condition: boolean, err?: Error | string): asserts condition {
if (!condition) throw typeof err === 'string' ? new Error(err) : err
}
let counter = 0
export async function listenTo<Args extends any[] = any[]>(
export async function listenTo(
event: string,
target: 'document' | 'window',
callback: (...args: Args) => void,
callback: <Args extends unknown[]>(...args: Args) => void,
oneTime?: boolean,
) {
const callbackName = 'onEvent' + ++counter
await page.exposeFunction(callbackName, callback)
await page.evaluate(
(event, target, callbackName, oneTime) => {
(event: string, target: 'window' | 'document', callbackName: string, oneTime?: boolean) => {
const t = target === 'document' ? document : window
const onEvent = (...args: any[]): void => {
;((window[callbackName as any] as any) as (...args: any[]) => void)(...args)
const onEvent = (...args: unknown[]): void => {
const method = window[callbackName as keyof Window]
method?.(...args)
if (oneTime) t.removeEventListener(event, onEvent)
}
t.addEventListener(event, onEvent)
@ -74,6 +72,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}"]`
}
@ -84,7 +100,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 }

26
babel.config.js Normal file
View file

@ -0,0 +1,26 @@
// `.babelrc` is not loaded by babel-loader for files under node_modules, but `babel.config.js` is
module.exports = {
env: {
test: {
plugins: ['babel-plugin-transform-es2015-modules-commonjs'],
},
},
presets: [
[
'@babel/preset-env',
{
modules: false,
targets: {
esmodules: true,
},
exclude: [
'@babel/plugin-transform-async-to-generator',
'@babel/plugin-proposal-object-rest-spread',
],
},
],
'@babel/preset-typescript',
'@babel/preset-react',
],
plugins: ['@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-class-properties'],
}

View file

@ -64,10 +64,7 @@ module.exports = {
maxWorkers: 8,
// An array of directory names to be searched recursively up from the requiring module's location
moduleDirectories: [
"src",
"node_modules"
],
moduleDirectories: ['src', 'node_modules'],
// An array of file extensions your modules use
// moduleFileExtensions: [
@ -133,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

@ -1,6 +1,6 @@
{
"name": "gitako",
"version": "3.6.2",
"version": "3.9.0",
"description": "File tree for GitHub, and more than that.",
"repository": "https://github.com/EnixCoda/Gitako",
"author": "EnixCoda",
@ -8,77 +8,91 @@
"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 -s dist",
"analyse-bundle": "ANALYSE= NODE_ENV=production webpack",
"postinstall": "rm -rf node_modules/@types/react-native && node scripts/fix-pjax-api",
"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",
"analyze-bundle": "ANALYZE= NODE_ENV=production webpack",
"postinstall": "node scripts/fix-deps",
"postversion": "sh scripts/post-version.sh",
"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:unit": "NODE_ENV=test jest --config jest.config.js",
"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"
},
"dependencies": {
"@primer/components": "^22.0.2",
"@primer/css": "^15.2.0",
"@primer/octicons-react": "^10.0.0",
"@primer/css": "^20.4.3",
"@primer/octicons-react": "^17.4.1",
"@primer/react": "^35.8.0",
"@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.5",
"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",
"webext-domain-permission-toggle": "^1.0.0",
"webext-dynamic-content-scripts": "^6.0.3",
"webextension-polyfill": "^0.5.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": "^3.0.0",
"webext-dynamic-content-scripts": "^8.1.1",
"webextension-polyfill": "^0.10.0"
},
"devDependencies": {
"@babel/cli": "^7.2.3",
"@babel/core": "^7.3.4",
"@babel/plugin-proposal-class-properties": "^7.3.4",
"@babel/plugin-proposal-optional-chaining": "^7.6.0",
"@babel/preset-env": "^7.3.4",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.3.3",
"@babel/cli": "^7.17.6",
"@babel/core": "^7.17.9",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
"@babel/preset-env": "^7.16.11",
"@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/jest": "^29.2.2",
"@types/node": "^11.10.4",
"@types/puppeteer": "^5.4.3",
"babel-loader": "^8.0.5",
"@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^5.33.1",
"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": "^0.5.2",
"jest": "^27.0.6",
"jest-puppeteer": "^5.0.4",
"fork-ts-checker-webpack-plugin": "^6.5.0",
"husky": "^8.0.1",
"jest": "^29.2.2",
"jest-environment-jsdom": "^29.2.2",
"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.2.4",
"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"
@ -90,8 +104,17 @@
"trailingComma": "all",
"arrowParens": "avoid"
},
"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"
}
}

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

19
scripts/post-version.sh Normal file
View file

@ -0,0 +1,19 @@
#!/bin/bash
# exit on error
set -e
# get current version
version=$(node scripts/get-version.js)
# remove git tag
git tag -d v$version
# update Safari version
make version-safari
# merge to previous git
git add .
git commit --amend --no-edit
# add git tag
git tag v$version

6
server/.eslintrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"env": {
"node": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
}

View file

@ -9,7 +9,7 @@ async function oauth(code: string) {
code: code,
client_id: GITEE_OAUTH_CLIENT_ID,
client_secret: GITEE_OAUTH_CLIENT_SECRET,
redirect_uri: 'https://gitako.now.sh/redirect/',
redirect_uri: 'https://gitako.enix.one/redirect/',
})
const res = await fetch('https://gitee.com/oauth/token?' + params.toString(), {

View file

@ -7,7 +7,7 @@
"dependencies": {
"@now/node": "^1.5.0",
"@types/node-fetch": "^2.5.5",
"node-fetch": "^2.6.0",
"node-fetch": "^2.6.7",
"typescript": "^3.8.3"
}
}

View file

@ -60,12 +60,32 @@ mime-types@^2.1.12:
dependencies:
mime-db "1.43.0"
node-fetch@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-fetch@^2.6.7:
version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
dependencies:
whatwg-url "^5.0.0"
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
typescript@^3.8.3:
version "3.8.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061"
integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
whatwg-url@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0=
dependencies:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"

20
src/.eslintrc.json Normal file
View file

@ -0,0 +1,20 @@
{
"env": {
"browser": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"react-hooks/rules-of-hooks": "off" // for IIFC
}
}

View file

@ -1,12 +1,10 @@
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, forOf } from 'utils/general'
import { storageHelper, storageKeys } from 'utils/storageHelper'
const PUBLIC_KEY = 'd22ec5c9cc874539a51c78388c12e3b0'
const PROJECT_ID = '1406497'
const MAX_REPORT_COUNT = 10 // protect for error leaking
const MAX_REPORT_COUNT = 10 // prevent error overflow
let countReportedError = 0
const errorSet = new Set<string>([
@ -22,59 +20,89 @@ const disabledIntegrations: string[] = [
'GlobalHandlers',
'CaptureConsole',
]
const sentryOptions: Sentry.BrowserOptions = {
dsn: `https://${PUBLIC_KEY}@sentry.io/${PROJECT_ID}`,
release: VERSION,
environment: IN_PRODUCTION_MODE ? 'production' : 'development',
// Not safe to activate all integrations in non-Chrome environments where Gitako may not run in top context
// https://docs.sentry.io/platforms/javascript/#sdk-integrations
defaultIntegrations: IN_PRODUCTION_MODE ? undefined : false,
integrations: integrations =>
integrations.filter(({ name }) => !disabledIntegrations.includes(name)),
beforeSend(event) {
const message = event.exception?.values?.[0].value || event.message
if (message) {
if (errorSet.has(message)) return null
errorSet.add(message) // prevent reporting duplicated error
}
if (countReportedError < MAX_REPORT_COUNT) {
++countReportedError
return event
}
return null
},
beforeBreadcrumb(breadcrumb, hint) {
if (breadcrumb.category === 'ui.click') {
const ariaLabel = hint?.event?.target?.ariaLabel
if (ariaLabel) {
breadcrumb.message = ariaLabel
}
}
return breadcrumb
},
autoSessionTracking: false, // this avoids the request when calling `init`
}
Sentry.init(sentryOptions)
export const withErrorLog: Middleware = function withErrorLog(method, args) {
return [
async function () {
try {
await method.apply(null, arguments as any)
} catch (error) {
raiseError(error)
let initiated = false
function init() {
if (initiated) return
initiated = true
const { PUBLIC_KEY, PROJECT_ID } = SENTRY
if (!PUBLIC_KEY || !PROJECT_ID) return
const sentryOptions: Sentry.BrowserOptions = {
dsn: `https://${PUBLIC_KEY}@sentry.io/${PROJECT_ID}`,
release: VERSION,
environment: IN_PRODUCTION_MODE ? 'production' : 'development',
// Not safe to activate all integrations in non-Chrome environments where Gitako may not run in top context
// https://docs.sentry.io/platforms/javascript/#sdk-integrations
defaultIntegrations: IN_PRODUCTION_MODE ? undefined : false,
integrations: integrations =>
integrations.filter(({ name }) => !disabledIntegrations.includes(name)),
beforeSend(event) {
const message = event.exception?.values?.[0].value || event.message
if (message) {
if (errorSet.has(message)) return null
errorSet.add(message) // prevent reporting duplicated error
}
} as any, // TO FIX: not sure how to fix this yet
args,
]
if (countReportedError < MAX_REPORT_COUNT) {
++countReportedError
return event
}
return null
},
beforeBreadcrumb(breadcrumb, hint) {
if (breadcrumb.category === 'ui.click') {
const ariaLabel = hint?.event?.target?.ariaLabel
if (ariaLabel) {
breadcrumb.message = ariaLabel
}
}
return breadcrumb
},
autoSessionTracking: false, // this avoids the request when calling `init`
}
Sentry.init(sentryOptions)
}
export function raiseError(
// 1. Only cache errors for current version, so that future errors can still be exposed
// - Run migration to clean on every update
// 2. Only cache the top 2 levels of stack, e.g.
// ```
// Error: cannot get current branch
// at Module.getCurrentBranch (chrome-extension://______id______/index.js:1:1)"
// ```
// So that different initial callees would not result in multiple records
const MAX_STACK_LEVEL = 2
const hasTheErrorBeenReported = atomicAsyncFunction(async function hasTheErrorBeenReported(
error: Error,
) {
const message = error.stack?.split('\n').slice(0, MAX_STACK_LEVEL).join('\n')
if (!message) return true // ignore errors that has no stack
type ErrorCache = string
const cache: ErrorCache[] =
((await storageHelper.get(storageKeys.raiseErrorCache))?.[
storageKeys.raiseErrorCache
] as string[]) || []
const has = cache.includes(message)
if (!has) {
cache.push(message)
await storageHelper.set({ [storageKeys.raiseErrorCache]: cache })
}
return has
})
export async function raiseError(
error: Error,
extra?: {
[key: string]: any
[key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
},
) {
if (await hasTheErrorBeenReported(error)) return
if (!IN_PRODUCTION_MODE || platform.isEnterprise()) {
// ignore errors from enterprise to get less noise on Sentry
console.error(error)
@ -82,11 +110,10 @@ export function raiseError(
return
}
init()
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)
})

9
src/common.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
// Similar to `global.d.ts` but with import/export
import { Dispatch, SetStateAction } from 'react'
type ReactIO<T> = {
value: T
onChange: Dispatch<SetStateAction<T>>
}
type PropsWithChildren = React.PropsWithChildren<Record<string, unknown>>

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,34 +0,0 @@
import * as React from 'react'
import { Icon } from './Icon'
export function DiffStatText({
diff: { status, changes, additions, deletions },
}: {
diff: Required<TreeNode>['diff']
}) {
return (
<span className={'diff-stat-text'}>
{status !== 'modified' && (
<Icon
className={status}
type={
{
added: 'diffAdded',
ignored: 'diffIgnored',
// modified: 'diffModified', // hide modified icon
removed: 'diffRemoved',
renamed: 'diffRenamed',
}[status]
}
/>
)}
{additions > 0 && (
<span className={'additions'}>{status === 'modified' ? `+${additions}` : additions}</span>
)}
{additions > 0 && deletions > 0 && '/'}
{deletions > 0 && (
<span className={'deletions'}>{status === 'modified' ? `-${deletions}` : deletions}</span>
)}
</span>
)
}

View file

@ -1,316 +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)[] = []
renders.push(renderFileStatus)
if (commentToggle) renders.push(renderFileCommentAmounts)
if (searchMode === 'fuzzy') renders.push(renderFindInFolderButton)
if (searched) renders.push(renderGoToButton)
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}
>
{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

@ -0,0 +1,25 @@
import * as React from 'react'
import { Icon } from '../Icon'
const iconMap = {
added: 'diffAdded',
ignored: 'diffIgnored',
modified: 'diffModified',
removed: 'diffRemoved',
renamed: 'diffRenamed',
}
export function DiffStatText({
diff: { status, additions, deletions },
}: {
diff: Required<TreeNode>['diff']
}) {
return (
<span className={'diff-stat-text'}>
<Icon className={status} type={iconMap[status]} />
{additions > 0 && <span className={'additions'}>{additions}</span>}
{additions > 0 && deletions > 0 && '/'}
{deletions > 0 && <span className={'deletions'}>{deletions}</span>}
</span>
)
}

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,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,160 @@
import { SidebarContext } from 'components/SidebarContext'
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,
) {
const { pendingFocusTarget } = React.useContext(SidebarContext)
const setPendingFocusTarget = pendingFocusTarget.onChange
return React.useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
const { nodes, focusedNode, expandedNodes } = visibleNodes
const handleVerticalMove = (index: number) => {
if (0 <= index && index < nodes.length) {
setPendingFocusTarget('files')
setAlignMode('lazy')
focusNode(nodes[index])
} else {
setPendingFocusTarget('search')
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':
setPendingFocusTarget('files')
focusNode(nodes[0])
break
case 'ArrowUp':
setPendingFocusTarget('files')
focusNode(nodes[nodes.length - 1])
break
default:
muteEvent = false
}
if (muteEvent) {
event.preventDefault()
}
}
}
},
[visibleNodes, searched, goTo, focusNode, toggleExpansion, setAlignMode, setPendingFocusTarget],
)
}

View file

@ -0,0 +1,109 @@
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 { DiffStatText } from '../DiffStatText'
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() {
const { showDiffInText } = useConfigs().value
return React.useCallback(
function renderFileStatus({ diff }: TreeNode) {
return (
diff && (
<span
className={'node-item-diff'}
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
>
{showDiffInText ? <DiffStatText diff={diff} /> : <DiffStatGraph diff={diff} />}
</span>
)
)
},
[showDiffInText],
)
}
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 { useAbortableEffect } from 'utils/hooks/useAbortableEffect'
import { useCatchNetworkError } from 'utils/hooks/useCatchNetworkError'
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
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
useAbortableEffect(
useCallback(
signal => {
catchNetworkErrors(async () => {
if (!metaData) return
if (signal.aborted) return
setStateContext('tree-loading')
const { userName, repoName, branchName } = metaData
const { root: treeRoot, defer = false } = await platform.getTreeData(
{
branchName,
userName,
repoName,
},
'/',
true,
config.accessToken,
)
if (signal.aborted) 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,238 @@
import { Label, Text } from '@primer/react'
import { useFocusOnPendingTarget } from 'components/FocusTarget'
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 { 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)
const goToCurrentItem = React.useCallback(() => {
const targetPath = platform.getCurrentPath(metaData.branchName)
if (targetPath) expandTo(targetPath)
}, [metaData.branchName, expandTo])
useOnLocationChange(goToCurrentItem)
useAfterRedirect(goToCurrentItem)
const ref = React.useRef<HTMLDivElement | null>(null)
useFocusOnPendingTarget(
'files',
React.useCallback(() => ref.current?.focus(), []),
)
return (
<div ref={ref} 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,
})}
ref={filesRef}
>
<div
style={{
height,
width: '100%',
overflow: 'auto',
position: 'absolute', // This allows reducing `height` on viewport height reduce
}}
ref={scrollElementRef}
onScroll={onScroll}
tabIndex={-1} // prevent getting focus via tab key on GitHub
>
<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
}

View file

@ -0,0 +1,14 @@
import * as React from 'react'
import { SidebarContext } from './SidebarContext'
export type FocusTarget = 'files' | 'search' | null
export function useFocusOnPendingTarget(target: FocusTarget, method: () => void) {
const { pendingFocusTarget } = React.useContext(SidebarContext)
React.useEffect(() => {
if (pendingFocusTarget.value === target) {
method()
pendingFocusTarget.onChange(null)
}
}, [target, method, pendingFocusTarget])
}

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,45 @@
import * as React from 'react'
export function Highlight(props: { text: string; match?: RegExp | string }) {
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')
}
return null
}, [match])
if (!$match) return <>{text}</>
const matchedPieces = Array.from(text.matchAll($match)).map(
([text, highlightText = text]) => highlightText,
export const Highlight = function Highlight({ text, match }: { text: string; match?: RegExp }) {
const $match = React.useMemo(
() =>
match instanceof RegExp
? match.flags.includes('g')
? match
: new RegExp(match.source, 'g' + match.flags)
: null,
[match],
)
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 chunks = React.useMemo(() => getChunks(text, $match), [text, $match])
return <>{chunks.map(([type, text], key) => React.createElement(type, { key }, text))}</>
}
type ElementMeta = [tag: string, content: string]
function getChunks(text: string, match: RegExp | null): ElementMeta[] {
const contents: ElementMeta[] = []
if (match === null) {
contents.push(['span', text])
return contents
}
return <>{content}</>
const matchedPieces = Array.from(text.matchAll(match)).map(
([text, highlightText = text]) => highlightText,
)
const preservedPieces = text.split(match)
const push = (type: string, text: string) => {
const last = contents[contents.length - 1]
if (last && last[0] === type) last[1] += text
else contents.push([type, text])
}
const max = Math.max(matchedPieces.length, preservedPieces.length)
for (let i = 0; i < max; ++i) {
preservedPieces[i] && push('span', preservedPieces[i])
matchedPieces[i] && push('mark', matchedPieces[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 disabled={rest.disabled}>
<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,62 @@
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,
}}
disabled={selectProps.disabled}
>
<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,49 @@
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',
wordBreak: 'normal',
overflowWrap: 'anywhere',
}}
{...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,9 +1,9 @@
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 { formatWithShortcut, isValidRegexpSource } from 'utils/general'
import { useFocusOnPendingTarget } from './FocusTarget'
import { SearchMode } from './searchModes'
type Props = {
@ -12,37 +12,45 @@ type Props = {
} & Required<Pick<TextInputProps, 'onFocus'>>
export function SearchBar({ onSearch, onFocus, value }: Props) {
const configs = useConfigs()
const { searchMode } = configs.value
const ref = React.useRef<HTMLInputElement | null>(null)
useFocusOnPendingTarget(
'search',
React.useCallback(() => ref.current?.focus(), []),
)
const toggleButtonDescription = `${
const configs = useConfigs()
const { searchMode, focusSearchInputShortcut } = configs.value
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
ref={ref}
leadingVisual={SearchIcon}
onFocus={e => {
onFocus(e)
e.target.select()
}}
block
sx={{ borderRadius: 0 }}
className={'search-input'}
aria-label="search files"
placeholder={formatWithShortcut(`Search files`, focusSearchInputShortcut)}
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 +59,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,215 +1,313 @@
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, formatWithShortcut } 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 { ResizeState } from 'utils/hooks/useResizeHandler'
import { useStateIO } from 'utils/hooks/useStateIO'
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 { useOnShortcutPressed } from '../utils/hooks/useOnShortcutPressed'
import { FocusTarget } from './FocusTarget'
import { LoadingIndicator } from './LoadingIndicator'
import { RoundIconButton } from './RoundIconButton'
import { SettingsBarContent } from './settings/SettingsBar'
import { SidebarContext } from './SidebarContext'
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
React.useEffect(() => {
if (sidebarToggleMode === 'persistent') {
DOMHelper.setBodyIndent(shouldShow)
} else {
DOMHelper.setBodyIndent(false)
}
if (shouldShow) {
DOMHelper.focusFileExplorer() // TODO: verify if it works
}
}, [shouldShow, sidebarToggleMode])
// 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()
useFocusSidebarOnExpand(shouldExpand)
const pendingFocusTarget = useStateIO<FocusTarget>(null)
useShowSidebarKeyboard(
shouldExpand,
setShouldExpand,
toggleShowSideBar,
pendingFocusTarget.onChange,
)
const toggleShowSideBar = React.useCallback(() => {
if (!error) $shouldShow.onChange(show => !show)
}, [error])
useToggleSideBarWithKeyboard(state, configContext, toggleShowSideBar)
const configContext = useConfigs()
const updateSideBarVisibility = React.useCallback(() => {
if (intelligentToggle === null && sidebarToggleMode === 'persistent') {
setShowSideBar(platform.shouldShow())
}
}, [intelligentToggle, sidebarToggleMode])
const blockLeaveRef = React.useRef(false)
const { sidebarToggleMode, shortcut, focusSearchInputShortcut } = configContext.value
const onResizeStateChange = React.useCallback((state: ResizeState) => {
blockLeaveRef.current = state === 'resizing'
}, [])
useOnPJAXDone(updateSideBarVisibility)
const heightForSafari = useConditionalHook(
() => detectBrowser() === 'Safari',
() => useWindowSize().height, // eslint-disable-line react-hooks/rules-of-hooks
)
platform.usePlatformHooks?.()
usePJAX()
// 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])
const sidebarContextValue = React.useMemo(() => ({ pendingFocusTarget }), [pendingFocusTarget])
return (
<Theme>
<div className={'gitako-side-bar'}>
<Portal into={$logoContainerElement.value}>
<ToggleShowButton
error={error}
className={cx({
hidden: shouldShow,
<SidebarContext.Provider value={sidebarContextValue}>
<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'}>
<div
className={cx('gitako-side-bar-body-wrapper', `toggle-mode-${sidebarToggleMode}`, {
collapsed: error || !shouldExpand,
})}
onHover={sidebarToggleMode === 'float' ? () => setShowSideBar(true) : undefined}
onClick={toggleShowSideBar}
/>
</Portal>
<SideBarBodyWrapper
className={cx(`toggle-mode-${sidebarToggleMode}`, {
collapsed: error || !shouldShow,
})}
baseSize={baseSize}
onLeave={sidebarToggleMode === 'float' ? () => setShowSideBar(false) : undefined}
sizeVariableMountPoint={sidebarToggleMode === 'persistent' ? document.body : undefined}
>
<div className={'gitako-side-bar-body'}>
<div
className={'gitako-side-bar-content'}
onClick={showSettings ? toggleShowSettings : undefined}
>
<div className={'header'}>
<div className={'close-side-bar-button-position'}>
{sidebarToggleMode === 'persistent' && (
<button
title={'Collapse sidebar'}
className={'close-side-bar-button'}
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',
})}
onClick={() =>
configContext.onChange({
sidebarToggleMode: sidebarToggleMode === 'float' ? 'persistent' : 'float',
})
}
>
<Icon className={'action-icon'} type={'pin'} />
</button>
style={{ height: heightForSafari }}
onMouseLeave={() => {
if (blockLeaveRef.current) return
if (sidebarToggleMode === 'float') setShouldExpand(false)
}}
>
<div className={'gitako-side-bar-body'}>
<div className={'gitako-side-bar-content'}>
<div className={'header'}>
<div className={'side-bar-position-controls'}>
{sidebarToggleMode === 'persistent' && (
<RoundIconButton
icon={TabIcon}
aria-label={formatWithShortcut('Collapse sidebar', shortcut)}
sx={{
transform: 'rotateY(180deg)',
}}
onClick={toggleShowSideBar}
/>
)}
<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 === 'persistent' ? 'float' : 'persistent',
})
}
/>
</div>
<MetaBar />
</div>
{metaData && <MetaBar metaData={metaData} />}
<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>
{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>
{() => {
const [showSettings, setShowSettings] = React.useState(false)
const toggleShowSettings = React.useCallback(
() => setShowSettings(show => !show),
[],
)
useOnShortcutPressed(
focusSearchInputShortcut,
React.useCallback(() => setShowSettings(false), []),
)
return (
<>
{showSettings && <SettingsBarContent toggleShow={toggleShowSettings} />}
<Footer toggleShowSettings={toggleShowSettings} />
</>
)
}}
</IIFC>
</div>
<SettingsBar toggleShowSettings={toggleShowSettings} activated={showSettings} />
{features.resize && <SideBarResizeHandler onResizeStateChange={onResizeStateChange} />}
</div>
</SideBarBodyWrapper>
</div>
</div>
</SidebarContext.Provider>
</Theme>
)
}
function useFocusSidebarOnExpand(shouldExpand: boolean) {
React.useEffect(() => {
// prevent keeping focus within Gitako
if (!shouldExpand) document.body.focus()
}, [shouldExpand])
}
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 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)
useCollapseOnNoPermissionWhenTokenHasBeenSet(setShouldExpand)
return [shouldExpand, setShouldExpand, toggleShowSideBar] as const
}
function useShowSidebarKeyboard(
shouldExpand: boolean,
setShouldExpand: React.Dispatch<React.SetStateAction<boolean>>,
toggleShowSideBar: () => void,
setFocusTarget: React.Dispatch<React.SetStateAction<FocusTarget>>,
) {
const config = useConfigs().value
useOnShortcutPressed(
config.shortcut,
React.useCallback(
e => {
DOMHelper.cancelEvent(e)
toggleShowSideBar()
if (!shouldExpand) setFocusTarget('files')
},
[shouldExpand, toggleShowSideBar, setFocusTarget],
),
)
useOnShortcutPressed(
config.focusSearchInputShortcut,
React.useCallback(
e => {
DOMHelper.cancelEvent(e)
if (!shouldExpand) setShouldExpand(true)
setFocusTarget('search')
},
[shouldExpand, setShouldExpand, setFocusTarget],
),
)
}

View file

@ -1,129 +0,0 @@
import { ResizeHandler } from 'components/ResizeHandler'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { useDebounce, useWindowSize } from 'react-use'
import { defaultConfigs } from 'utils/config/helper'
import { 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])
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(defaultConfigs.sideBarWidth)
apply(sizeVariableMountPoint, defaultConfigs.sideBarWidth)
}}
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

@ -0,0 +1,10 @@
import React from 'react'
import { noop } from 'utils/general'
import { FocusTarget } from './FocusTarget'
// Use this to pass state across components under Sidebar
export const SidebarContext = React.createContext<{
pendingFocusTarget: IO<FocusTarget>
}>({
pendingFocusTarget: { onChange: noop, value: null },
})

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,

Some files were not shown because too many files have changed in this diff Show more