Merge branch 'develop' into manifest-v3
# Conflicts: # src/manifest.json
25
.babelrc
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
});
|
||||
46
.github/workflows/release-assets.yml
vendored
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -5,3 +5,4 @@ tmp
|
|||
dist
|
||||
yarn-error.log
|
||||
vscode-icons
|
||||
firefox-profile
|
||||
|
|
|
|||
4
.husky/pre-commit
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged --quiet
|
||||
4
.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
*-profile/
|
||||
dist/
|
||||
vscode-icons/
|
||||
Safari
|
||||
4
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 873 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
|
@ -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
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"excludedFiles": ["*.d.ts"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
|
||||
}
|
||||
]
|
||||
}
|
||||
2
__tests__/babel.config.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// TS files cannot be transformed without this babel config
|
||||
module.exports = require('../babel.config')
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'],
|
||||
}
|
||||
|
|
@ -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: {},
|
||||
|
|
|
|||
105
package.json
|
|
@ -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
|
|
@ -0,0 +1,40 @@
|
|||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* This script rewrites local dependency files to resolve compatibility issues.
|
||||
* This is a bit dirty but really effective.
|
||||
*/
|
||||
|
||||
function modify(source = '', pairs = []) {
|
||||
for (const [original, replace] of pairs) {
|
||||
if (source.includes(original)) {
|
||||
source = source.replace(original, replace)
|
||||
} else {
|
||||
throw new Error(`Original string not found: ${JSON.stringify(original)}`)
|
||||
}
|
||||
|
||||
if (source.includes(original)) {
|
||||
throw new Error(`More than one original string found`, JSON.stringify(original))
|
||||
}
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
const nodeModulesPath = path.resolve(__dirname, '../../', `node_modules`)
|
||||
|
||||
exports.fixDep = async function fixDep(targetFilePath, pairs) {
|
||||
const filePath = path.resolve(nodeModulesPath, targetFilePath)
|
||||
const source = await fs.readFile(filePath, 'utf-8')
|
||||
const modified = modify(source, pairs)
|
||||
await fs.writeFile(filePath, modified, 'utf-8')
|
||||
}
|
||||
|
||||
async function fixDeps() {
|
||||
for (const fix of [require('./pjax-api').fix, require('./styled-components').fix]) {
|
||||
await fix()
|
||||
}
|
||||
}
|
||||
|
||||
fixDeps()
|
||||
45
scripts/fix-deps/pjax-api.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
const { fixDep } = require('.')
|
||||
|
||||
const targetFilePath = `pjax-api/dist/pjax-api.js`
|
||||
const pairs = [
|
||||
// Firefox
|
||||
[
|
||||
`void xhr.open(method, requestURL.path, true);`,
|
||||
`void xhr.open(method, requestURL.reference, true);`,
|
||||
],
|
||||
// Firefox
|
||||
[
|
||||
`this.document = this.xhr.responseXML.cloneNode(true);`,
|
||||
`this.document = this.xhr.responseXML;`,
|
||||
],
|
||||
// Chrome: modifying cross-context history state causes troubles
|
||||
// Scroll position can still be restored without this function
|
||||
[
|
||||
`
|
||||
function savePosition() {
|
||||
var _a;
|
||||
void window.history.replaceState({
|
||||
...window.history.state,
|
||||
position: {
|
||||
...(_a = window.history.state) === null || _a === void 0 ? void 0 : _a.position,
|
||||
top: window.pageYOffset,
|
||||
left: window.pageXOffset
|
||||
}
|
||||
}, document.title);
|
||||
}`,
|
||||
`
|
||||
function savePosition() {
|
||||
return;
|
||||
}`,
|
||||
],
|
||||
]
|
||||
|
||||
exports.fix = async () => {
|
||||
try {
|
||||
await fixDep(targetFilePath, pairs)
|
||||
} catch (err) {
|
||||
console.error((err && err.message) || err)
|
||||
const shouldTerminate = process.env.IGNORE_FIX_PJAX_API_FAILURE !== 'true'
|
||||
if (shouldTerminate) process.exit(1)
|
||||
}
|
||||
}
|
||||
20
scripts/fix-deps/styled-components.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const { fixDep } = require('.')
|
||||
|
||||
const targetFilePath = `styled-components/dist/styled-components.browser.esm.js`
|
||||
const pairs = [
|
||||
// Firefox
|
||||
// disable production check in `checkDynamicCreation`
|
||||
[
|
||||
`function(e,t){if("production"!==process.env.NODE_ENV)`, // prettier-ignore
|
||||
`function(e,t){if(false)`,
|
||||
],
|
||||
]
|
||||
|
||||
exports.fix = async () => {
|
||||
try {
|
||||
await fixDep(targetFilePath, pairs)
|
||||
} catch (err) {
|
||||
console.error((err && err.message) || err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* This script rewrites code of pjax-api to resolve compatibility issues.
|
||||
* This is a bit dirty but really effective.
|
||||
*/
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
|
||||
function modify(source = '', pairs = []) {
|
||||
for (const [original, replace] of pairs) {
|
||||
if (source.includes(original)) {
|
||||
source = source.replace(original, replace)
|
||||
} else {
|
||||
throw new Error(`Original string not found: ${JSON.stringify(original)}`)
|
||||
}
|
||||
|
||||
if (source.includes(original)) {
|
||||
throw new Error(`More than one original string found`, JSON.stringify(original))
|
||||
}
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
async function fixPJAXAPI(loose) {
|
||||
const pairs = [
|
||||
// Firefox
|
||||
[
|
||||
`void xhr.open(method, requestURL.path, true);`,
|
||||
`void xhr.open(method, requestURL.reference, true);`,
|
||||
],
|
||||
// Firefox
|
||||
[
|
||||
`this.document = this.xhr.responseXML.cloneNode(true);`,
|
||||
`this.document = this.xhr.responseXML;`,
|
||||
],
|
||||
// Chrome: modifying cross-context history state causes troubles
|
||||
// Scroll position can still be restored without this function
|
||||
[
|
||||
`
|
||||
function savePosition() {
|
||||
var _a;
|
||||
void window.history.replaceState({
|
||||
...window.history.state,
|
||||
position: {
|
||||
...(_a = window.history.state) === null || _a === void 0 ? void 0 : _a.position,
|
||||
top: window.pageYOffset,
|
||||
left: window.pageXOffset
|
||||
}
|
||||
}, document.title);
|
||||
}`,
|
||||
`
|
||||
function savePosition() {
|
||||
return;
|
||||
}`,
|
||||
],
|
||||
]
|
||||
try {
|
||||
const filePath = path.resolve(__dirname, '..', `node_modules/pjax-api/dist/pjax-api.js`)
|
||||
const source = await fs.readFile(filePath, 'utf-8')
|
||||
const modified = modify(source, pairs, loose)
|
||||
await fs.writeFile(filePath, modified, 'utf-8')
|
||||
} catch (err) {
|
||||
console.error((err && err.message) || err)
|
||||
const shouldTerminate = process.env.IGNORE_FIX_PJAX_API_FAILURE !== 'true'
|
||||
if (shouldTerminate) process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
fixPJAXAPI()
|
||||
19
scripts/post-version.sh
Normal 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
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
|
||||
}
|
||||
|
|
@ -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(), {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
137
src/analytics.ts
|
|
@ -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
|
|
@ -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>>
|
||||
|
|
@ -5,8 +5,8 @@ import { GitHub } from 'platforms/GitHub'
|
|||
import * as React from 'react'
|
||||
|
||||
export function AccessDeniedDescription() {
|
||||
const configContext = useConfigs()
|
||||
const hasToken = Boolean(configContext.value.accessToken)
|
||||
const { accessToken } = useConfigs().value
|
||||
const hasToken = Boolean(accessToken)
|
||||
|
||||
return (
|
||||
<div className={'description-area'}>
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ export function Clippy({ codeSnippetElement }: Props) {
|
|||
React.useEffect(() => {
|
||||
const element = elementRef.current
|
||||
if (element) {
|
||||
function onClippyClick() {
|
||||
const onClippyClick = () =>
|
||||
setState(copyElementContent(codeSnippetElement) ? 'success' : 'fail')
|
||||
}
|
||||
|
||||
element.addEventListener('click', onClippyClick)
|
||||
return () => element.removeEventListener('click', onClippyClick)
|
||||
}
|
||||
}, [])
|
||||
}, [codeSnippetElement])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
|
|
|||
|
|
@ -1,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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
25
src/components/FileExplorer/DiffStatText.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
src/components/FileExplorer/ListView.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { useStateIO } from 'utils/hooks/useStateIO'
|
||||
import { NodeRendererContext } from '.'
|
||||
import { Node } from './Node'
|
||||
import { AlignMode, useVirtualScroll } from './useVirtualScroll'
|
||||
|
||||
type ListViewProps = {
|
||||
height: number
|
||||
width: number
|
||||
nodeRendererContext: NodeRendererContext
|
||||
}
|
||||
|
||||
export function ListView({ width, height, nodeRendererContext }: ListViewProps) {
|
||||
const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } =
|
||||
nodeRendererContext
|
||||
const { focusedNode, nodes, expandedNodes, depths, loading } = visibleNodes
|
||||
|
||||
const { compactFileTree } = useConfigs().value
|
||||
|
||||
const rowHeight = compactFileTree ? 24 : 37
|
||||
const totalAmount = visibleNodes.nodes.length
|
||||
const { onScroll, visibleRows, containerStyle, scrollToItem, ref } =
|
||||
useVirtualScroll<HTMLDivElement>({
|
||||
totalAmount,
|
||||
rowHeight,
|
||||
viewportHeight: height,
|
||||
overScan: 10,
|
||||
})
|
||||
|
||||
const $mode = useStateIO<AlignMode>('top')
|
||||
const enableScroll = width * height > 0 // these can be 0 on first render
|
||||
|
||||
const index = React.useMemo(
|
||||
() =>
|
||||
width && height && focusedNode?.path
|
||||
? nodes.findIndex(node => node.path === focusedNode.path)
|
||||
: -1,
|
||||
[focusedNode?.path, nodes, width, height],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
// - init loading
|
||||
// - "top"
|
||||
// - NO immediate call
|
||||
// - jump to file
|
||||
// - "top"
|
||||
// - NO immediate call
|
||||
// - click file/folder
|
||||
// - not invoke
|
||||
// - navigate with keyboard
|
||||
// - "lazy"
|
||||
// - immediate call
|
||||
if (enableScroll && index !== -1) {
|
||||
scrollToItem?.(index, $mode.value)
|
||||
}
|
||||
}, [enableScroll, $mode.value, index, scrollToItem])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enableScroll && $mode.value === 'top') $mode.onChange('lazy')
|
||||
}, [enableScroll, $mode.value]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
ref={ref}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<div style={containerStyle}>
|
||||
{visibleRows.map(({ row, style }) => {
|
||||
const node = nodes[row]
|
||||
return (
|
||||
<Node
|
||||
key={node.path}
|
||||
node={node}
|
||||
style={style}
|
||||
depth={depths.get(node) || 0}
|
||||
focused={focusedNode?.path === node.path}
|
||||
loading={loading.has(node.path)}
|
||||
expanded={expandedNodes.has(node.path)}
|
||||
onClick={onNodeClick}
|
||||
onFocus={onNodeFocus}
|
||||
renderLabelText={renderLabelText}
|
||||
renderActions={renderActions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { platform } from 'platforms'
|
|||
import * as React from 'react'
|
||||
import { cx } from 'utils/cx'
|
||||
import { getFileIconURL, getFolderIconURL } from 'utils/parseIconMapCSV'
|
||||
import { Icon } from './Icon'
|
||||
import { Icon } from '../Icon'
|
||||
|
||||
function getIconType(node: TreeNode) {
|
||||
switch (node.type) {
|
||||
|
|
@ -19,6 +19,7 @@ function getIconType(node: TreeNode) {
|
|||
type Props = {
|
||||
node: TreeNode
|
||||
onClick(event: React.MouseEvent<HTMLElement, MouseEvent>, node: TreeNode): void
|
||||
onFocus(event: React.FocusEvent<HTMLElement, Element>, node: TreeNode): void
|
||||
depth: number
|
||||
expanded: boolean
|
||||
focused: boolean
|
||||
|
|
@ -27,7 +28,8 @@ type Props = {
|
|||
renderLabelText(node: TreeNode): React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
export function Node({
|
||||
|
||||
export const Node = React.memo(function Node({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
|
|
@ -37,17 +39,20 @@ export function Node({
|
|||
renderLabelText,
|
||||
style,
|
||||
onClick,
|
||||
onFocus,
|
||||
}: Props) {
|
||||
const { compactFileTree: compact } = useConfigs().value
|
||||
return (
|
||||
<a
|
||||
href={node.url}
|
||||
onClick={event => onClick(event, node)}
|
||||
onFocus={event => onFocus(event, node)}
|
||||
className={cx(`node-item`, { focused, disabled: node.accessDenied, expanded, compact })}
|
||||
style={{ ...style, paddingLeft: `${10 + (compact ? 10 : 20) * depth}px` }}
|
||||
title={node.path}
|
||||
target={node.type === 'commit' ? '_blank' : undefined}
|
||||
{...platform.delegatePJAXProps?.({ node })}
|
||||
rel="noopener noreferrer"
|
||||
{...platform.delegateFastRedirectAnchorProps?.({ node })}
|
||||
>
|
||||
<div className={'node-item-label'}>
|
||||
<NodeItemIcon node={node} open={expanded} loading={loading} />
|
||||
|
|
@ -56,7 +61,7 @@ export function Node({
|
|||
{renderActions && <div className={'actions'}>{renderActions(node)}</div>}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const NodeItemIcon = React.memo(function NodeItemIcon({
|
||||
node,
|
||||
|
|
@ -67,25 +72,24 @@ const NodeItemIcon = React.memo(function NodeItemIcon({
|
|||
open?: boolean
|
||||
loading?: boolean
|
||||
}) {
|
||||
const {
|
||||
value: { icons },
|
||||
} = useConfigs()
|
||||
const { icons } = useConfigs().value
|
||||
|
||||
const src = React.useMemo(
|
||||
() => (node.type === 'tree' ? getFolderIconURL(node, open) : getFileIconURL(node)),
|
||||
[open],
|
||||
[node, open],
|
||||
)
|
||||
const iconType = React.useMemo(() => getIconType(node), [node])
|
||||
|
||||
if (icons === 'native') return <Icon type={getIconType(node)} />
|
||||
if (icons === 'native') return <Icon type={iconType} />
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
className={'node-item-type-icon'}
|
||||
placeholder={node.type !== 'tree'}
|
||||
type={loading ? 'loading' : getIconType(node)}
|
||||
type={loading ? 'loading' : iconType}
|
||||
/>
|
||||
{node.type === 'commit' ? (
|
||||
<Icon type={getIconType(node)} />
|
||||
<Icon type={iconType} />
|
||||
) : (
|
||||
<img alt={node.name} className={cx('node-item-icon', { dim: icons === 'dim' })} src={src} />
|
||||
)}
|
||||
32
src/components/FileExplorer/VirtualNode.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Node } from 'components/FileExplorer/Node'
|
||||
import * as React from 'react'
|
||||
import { ListChildComponentProps } from 'react-window'
|
||||
import { NodeRendererContext } from '.'
|
||||
|
||||
export const VirtualNode = React.memo(function VirtualNode({
|
||||
index,
|
||||
style,
|
||||
data,
|
||||
}: Override<ListChildComponentProps, { data: NodeRendererContext }>) {
|
||||
const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } = data
|
||||
if (!visibleNodes) return null
|
||||
|
||||
const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes
|
||||
const node = nodes[index]
|
||||
|
||||
return (
|
||||
<Node
|
||||
style={style}
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={depths.get(node) || 0}
|
||||
focused={focusedNode?.path === node.path}
|
||||
loading={loading.has(node.path)}
|
||||
expanded={expandedNodes.has(node.path)}
|
||||
onClick={onNodeClick}
|
||||
onFocus={onNodeFocus}
|
||||
renderLabelText={renderLabelText}
|
||||
renderActions={renderActions}
|
||||
/>
|
||||
)
|
||||
})
|
||||
12
src/components/FileExplorer/hooks/useExpandTo.tsx
Normal file
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
9
src/components/FileExplorer/hooks/useFocusNode.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import * as React from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
|
||||
export function useFocusNode(visibleNodesGenerator: VisibleNodesGenerator) {
|
||||
return React.useCallback(
|
||||
(node: TreeNode | null) => visibleNodesGenerator.focusNode(node),
|
||||
[visibleNodesGenerator],
|
||||
)
|
||||
}
|
||||
6
src/components/FileExplorer/hooks/useGetCurrentPath.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { platform } from 'platforms'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export function useGetCurrentPath({ branchName }: MetaData) {
|
||||
return useCallback(() => platform.getCurrentPath(branchName), [branchName])
|
||||
}
|
||||
18
src/components/FileExplorer/hooks/useGoTo.tsx
Normal file
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
160
src/components/FileExplorer/hooks/useHandleKeyDown.tsx
Normal 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],
|
||||
)
|
||||
}
|
||||
109
src/components/FileExplorer/hooks/useNodeRenderers.tsx
Normal 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} />
|
||||
|
||||
{node.comments.active > 9 ? '9+' : node.comments.active}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
const { commentToggle } = useConfigs().value
|
||||
return React.useMemo(() => (commentToggle ? renderFileCommentAmounts : null), [commentToggle])
|
||||
}
|
||||
|
||||
export function useRenderFindInFolderButton(
|
||||
onSearch: (searchKey: string, searchMode: SearchMode) => void,
|
||||
) {
|
||||
const { searchMode } = useConfigs().value
|
||||
return React.useMemo(
|
||||
() =>
|
||||
searchMode === 'fuzzy'
|
||||
? function renderFindInFolderButton(node: TreeNode) {
|
||||
return node.type === 'tree' ? (
|
||||
<button
|
||||
title={'Find in folder...'}
|
||||
className={'find-in-folder-button'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onSearch(node.path + '/', searchMode)
|
||||
}}
|
||||
>
|
||||
<Icon type="search" />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
: null,
|
||||
[searchMode, onSearch],
|
||||
)
|
||||
}
|
||||
|
||||
export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) => void) {
|
||||
return React.useMemo(
|
||||
() =>
|
||||
searched
|
||||
? function renderGoToButton(node: TreeNode): React.ReactNode {
|
||||
return (
|
||||
<button
|
||||
title={'Reveal in file tree'}
|
||||
className={'go-to-button'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
goTo(node.path.split('/'))
|
||||
}}
|
||||
>
|
||||
<Icon type="go-to" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
: null,
|
||||
[searched, goTo],
|
||||
)
|
||||
}
|
||||
50
src/components/FileExplorer/hooks/useOnNodeClick.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { isOpenInNewWindowClick } from 'utils/general'
|
||||
import { loadWithFastRedirect } from 'utils/hooks/useFastRedirect'
|
||||
import { AlignMode } from '../useVirtualScroll'
|
||||
import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods'
|
||||
|
||||
export function useHandleNodeClick(
|
||||
{ toggleExpansion, focusNode }: VisibleNodesGeneratorMethods,
|
||||
setAlignMode: (mode: AlignMode) => void,
|
||||
) {
|
||||
const { recursiveToggleFolder } = useConfigs().value
|
||||
return React.useCallback(
|
||||
(event: React.MouseEvent<HTMLElement, MouseEvent>, node: TreeNode) => {
|
||||
setAlignMode('lazy')
|
||||
switch (node.type) {
|
||||
case 'tree': {
|
||||
const recursive =
|
||||
(recursiveToggleFolder === 'shift' && event.shiftKey) ||
|
||||
(recursiveToggleFolder === 'alt' && event.altKey)
|
||||
// recursive toggle action may conflict with browser default action
|
||||
// e.g. shift + click is the default open in new tab action on macOS
|
||||
// giving recursive toggle action higher priority than default action
|
||||
if (!recursive && isOpenInNewWindowClick(event)) return
|
||||
|
||||
event.preventDefault()
|
||||
toggleExpansion(node, { recursive })
|
||||
break
|
||||
}
|
||||
case 'blob': {
|
||||
if (isOpenInNewWindowClick(event)) return
|
||||
|
||||
focusNode(node)
|
||||
if (node.url) {
|
||||
const isHashLink = node.url.includes('#')
|
||||
if (!isHashLink) {
|
||||
event.preventDefault()
|
||||
loadWithFastRedirect(node.url, event.currentTarget)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'commit': {
|
||||
// pass event, open in new tab thanks to the target="_blank" on the anchor element
|
||||
}
|
||||
}
|
||||
},
|
||||
[toggleExpansion, recursiveToggleFolder, focusNode, setAlignMode],
|
||||
)
|
||||
}
|
||||
21
src/components/FileExplorer/hooks/useOnSearch.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
import { SearchMode, searchModes } from '../../searchModes'
|
||||
|
||||
export function useOnSearch(
|
||||
updateSearchKey: (searchKey: string) => void,
|
||||
visibleNodesGenerator: VisibleNodesGenerator,
|
||||
) {
|
||||
const { restoreExpandedFolders } = useConfigs().value
|
||||
return React.useCallback(
|
||||
(searchKey: string, searchMode: SearchMode) => {
|
||||
updateSearchKey(searchKey)
|
||||
visibleNodesGenerator.search(
|
||||
searchModes[searchMode].getSearchParams(searchKey),
|
||||
restoreExpandedFolders,
|
||||
)
|
||||
},
|
||||
[updateSearchKey, visibleNodesGenerator, restoreExpandedFolders],
|
||||
)
|
||||
}
|
||||
11
src/components/FileExplorer/hooks/useRenderLabelText.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { searchModes } from '../../searchModes'
|
||||
|
||||
export function useRenderLabelText(searchKey: string) {
|
||||
const { searchMode } = useConfigs().value
|
||||
return React.useCallback(
|
||||
(node: TreeNode) => searchModes[searchMode].renderNodeLabelText(node, searchKey),
|
||||
[searchKey, searchMode],
|
||||
)
|
||||
}
|
||||
21
src/components/FileExplorer/hooks/useToggleExpansion.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from 'react'
|
||||
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
|
||||
|
||||
export function useToggleExpansion(visibleNodesGenerator: VisibleNodesGenerator) {
|
||||
return React.useCallback(
|
||||
async (
|
||||
node: TreeNode,
|
||||
{
|
||||
recursive = false,
|
||||
}: {
|
||||
recursive?: boolean
|
||||
},
|
||||
) => {
|
||||
if (node.type === 'tree') {
|
||||
visibleNodesGenerator.focusNode(node)
|
||||
await visibleNodesGenerator.toggleExpand(node, recursive)
|
||||
}
|
||||
},
|
||||
[visibleNodesGenerator],
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { platform } from 'platforms'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
238
src/components/FileExplorer/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/components/FileExplorer/useHandleNodeFocus.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import { VisibleNodesGeneratorMethods } from './hooks/useVisibleNodesGeneratorMethods'
|
||||
import { AlignMode } from './useVirtualScroll'
|
||||
|
||||
export function useHandleNodeFocus(
|
||||
{ focusNode }: VisibleNodesGeneratorMethods,
|
||||
setAlignMode: (mode: AlignMode) => void,
|
||||
) {
|
||||
return React.useCallback(
|
||||
(event: React.FocusEvent<HTMLElement, Element>, node: TreeNode) => {
|
||||
setAlignMode('lazy')
|
||||
focusNode(node)
|
||||
},
|
||||
[focusNode, setAlignMode],
|
||||
)
|
||||
}
|
||||
15
src/components/FileExplorer/useLatestValueRef.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import * as React from 'react'
|
||||
|
||||
function useLatestValueRef<T>(value: T) {
|
||||
const ref = React.useRef(value)
|
||||
React.useEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
return ref
|
||||
}
|
||||
export function useCallbackRef<Args extends AnyArray, R>(
|
||||
callback: (...args: Args) => R,
|
||||
): (...args: Args) => R {
|
||||
const ref = useLatestValueRef(callback)
|
||||
return React.useCallback((...args: Args) => ref.current(...args), [ref])
|
||||
}
|
||||
122
src/components/FileExplorer/useVirtualScroll.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import * as React from 'react'
|
||||
import { useCallbackRef } from './useLatestValueRef'
|
||||
|
||||
function memoize<Args extends AnyArray, R>(
|
||||
fn: (...args: Args) => R,
|
||||
serializeArguments: (...args: Args) => string | number,
|
||||
): (...args: Args) => R {
|
||||
const memory = new Map<string | number, R>()
|
||||
return (...args) => {
|
||||
const key = serializeArguments(...args)
|
||||
let r = memory.get(key)
|
||||
if (!r) memory.set(key, (r = fn(...args)))
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
export type AlignMode = 'top' | 'end' | 'lazy'
|
||||
|
||||
export function useVirtualScroll<E extends HTMLElement>({
|
||||
totalAmount,
|
||||
viewportHeight,
|
||||
rowHeight,
|
||||
overScan = 0,
|
||||
}: {
|
||||
totalAmount: number
|
||||
overScan?: number
|
||||
viewportHeight: number
|
||||
rowHeight: number
|
||||
}) {
|
||||
const totalHeight = totalAmount * rowHeight
|
||||
|
||||
const ref = React.useRef<E | null>(null) // TODO: compare DOM native event listener
|
||||
const [scrollTop, setScrollTop] = React.useState(0)
|
||||
|
||||
const onScroll = React.useCallback((e: React.UIEvent<E, UIEvent>) => {
|
||||
setScrollTop(e.currentTarget.scrollTop)
|
||||
}, [])
|
||||
|
||||
const [startRenderIndex, endRenderIndex] = React.useMemo(() => {
|
||||
const viewportLastItemOverflow = viewportHeight % rowHeight
|
||||
const visibleRowCount = (viewportHeight - viewportLastItemOverflow) / rowHeight
|
||||
const inViewIndexFirst = (Math.min(scrollTop, totalHeight - viewportHeight) / rowHeight) >> 0
|
||||
const inViewIndexLast = inViewIndexFirst + visibleRowCount
|
||||
const renderIndexFirst = Math.max(0, inViewIndexFirst - overScan)
|
||||
const renderIndexLast = Math.min(totalAmount, inViewIndexLast + overScan)
|
||||
return [renderIndexFirst, renderIndexLast]
|
||||
}, [scrollTop, viewportHeight, overScan, rowHeight, totalAmount, totalHeight])
|
||||
|
||||
const indexes = React.useMemo(() => {
|
||||
const indexes: number[] = []
|
||||
let i = startRenderIndex
|
||||
while (i < endRenderIndex) indexes.push(i++)
|
||||
return indexes
|
||||
}, [startRenderIndex, endRenderIndex])
|
||||
|
||||
const mapStyles = React.useCallback(
|
||||
(row: number): React.CSSProperties => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${row * rowHeight}px)`,
|
||||
width: '100%',
|
||||
height: rowHeight,
|
||||
}),
|
||||
[rowHeight],
|
||||
)
|
||||
const memoizedStyler = React.useMemo(() => memoize(mapStyles, row => row), [mapStyles])
|
||||
|
||||
const visibleRows: { row: number; style: React.CSSProperties }[] = React.useMemo(
|
||||
() =>
|
||||
indexes.map(row => ({
|
||||
row,
|
||||
style: memoizedStyler(row),
|
||||
})),
|
||||
[indexes, memoizedStyler],
|
||||
)
|
||||
|
||||
const containerStyle: React.CSSProperties = React.useMemo(
|
||||
() => ({
|
||||
height: totalHeight,
|
||||
position: 'relative',
|
||||
}),
|
||||
[totalHeight],
|
||||
)
|
||||
|
||||
const scrollToItem = useCallbackRef((row: number, mode: AlignMode) => {
|
||||
const getOffsetEnd = () => row * rowHeight + rowHeight - viewportHeight
|
||||
const getOffsetTop = () => row * rowHeight
|
||||
|
||||
const updateScrollPosition = (scrollTop: number) => {
|
||||
setScrollTop(scrollTop)
|
||||
|
||||
// Note: storing the scrollTop into a state and update DOM element scrollTop inside a layout effect would not work.
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'top':
|
||||
return updateScrollPosition(getOffsetTop())
|
||||
case 'end':
|
||||
return updateScrollPosition(getOffsetEnd())
|
||||
case 'lazy': {
|
||||
const isAbove = row * rowHeight < scrollTop
|
||||
const isBelow = row * rowHeight + rowHeight > scrollTop + viewportHeight
|
||||
if (isBelow) {
|
||||
updateScrollPosition(getOffsetEnd())
|
||||
} else if (isAbove) {
|
||||
updateScrollPosition(getOffsetTop())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ref,
|
||||
visibleRows,
|
||||
onScroll,
|
||||
containerStyle,
|
||||
scrollToItem,
|
||||
}
|
||||
}
|
||||
15
src/components/FileExplorer/useVisibleNodes.tsx
Normal file
|
|
@ -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
|
||||
}
|
||||
14
src/components/FocusTarget.tsx
Normal 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
|
|
@ -0,0 +1,33 @@
|
|||
import { GearIcon } from '@primer/octicons-react'
|
||||
import { Link } from '@primer/react'
|
||||
import { VERSION } from 'env'
|
||||
import * as React from 'react'
|
||||
import { RoundIconButton } from './RoundIconButton'
|
||||
import { wikiLinks } from './settings/SettingsBar'
|
||||
|
||||
type Props = {
|
||||
toggleShowSettings: () => void
|
||||
}
|
||||
|
||||
export function Footer(props: Props) {
|
||||
const { toggleShowSettings } = props
|
||||
return (
|
||||
<div className={'gitako-footer'}>
|
||||
<Link
|
||||
className={'version'}
|
||||
href={wikiLinks.changeLog}
|
||||
title={'Check out new features!'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{VERSION}
|
||||
</Link>
|
||||
<RoundIconButton
|
||||
aria-label={'settings'}
|
||||
icon={GearIcon}
|
||||
iconColor="fg.muted"
|
||||
onClick={toggleShowSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { SideBar } from 'components/SideBar'
|
||||
import { ConfigsContextWrapper } from 'containers/ConfigsContext'
|
||||
import { ReloadContextWrapper } from 'containers/ReloadContext'
|
||||
import { InspectorContextWrapper } from 'containers/StateInspector'
|
||||
import * as React from 'react'
|
||||
import { StyleSheetManager } from 'styled-components'
|
||||
import { insertMountPoint } from 'utils/DOMHelper'
|
||||
import { ErrorBoundary } from '../containers/ErrorBoundary'
|
||||
import { StateBarErrorContextWrapper } from '../containers/ErrorContext'
|
||||
import { OAuthWrapper } from '../containers/OAuthWrapper'
|
||||
|
|
@ -9,18 +13,24 @@ import { StateBarStateContextWrapper } from '../containers/SideBarState'
|
|||
|
||||
export function Gitako() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ConfigsContextWrapper>
|
||||
<StateBarStateContextWrapper>
|
||||
<StateBarErrorContextWrapper>
|
||||
<OAuthWrapper>
|
||||
<RepoContextWrapper>
|
||||
<SideBar />
|
||||
</RepoContextWrapper>
|
||||
</OAuthWrapper>
|
||||
</StateBarErrorContextWrapper>
|
||||
</StateBarStateContextWrapper>
|
||||
</ConfigsContextWrapper>
|
||||
</ErrorBoundary>
|
||||
<InspectorContextWrapper>
|
||||
<StyleSheetManager target={insertMountPoint()}>
|
||||
<ReloadContextWrapper>
|
||||
<ErrorBoundary>
|
||||
<ConfigsContextWrapper>
|
||||
<StateBarStateContextWrapper>
|
||||
<StateBarErrorContextWrapper>
|
||||
<OAuthWrapper>
|
||||
<RepoContextWrapper>
|
||||
<SideBar />
|
||||
</RepoContextWrapper>
|
||||
</OAuthWrapper>
|
||||
</StateBarErrorContextWrapper>
|
||||
</StateBarStateContextWrapper>
|
||||
</ConfigsContextWrapper>
|
||||
</ErrorBoundary>
|
||||
</ReloadContextWrapper>
|
||||
</StyleSheetManager>
|
||||
</InspectorContextWrapper>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
15
src/components/Highlight.test.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import React, { ComponentProps } from 'react'
|
||||
import { Highlight } from './Highlight'
|
||||
|
||||
function test(title: string, text: string, match?: ComponentProps<typeof Highlight>['match']) {
|
||||
it(title, () => {
|
||||
expect(render(<Highlight text={text} match={match} />).container.textContent).toBe(text)
|
||||
})
|
||||
}
|
||||
|
||||
test('sample', 'abc', undefined)
|
||||
test('sample', 'abc', /./)
|
||||
test('sample', 'abc', /../)
|
||||
test('sample', 'abc', /.../)
|
||||
test('sample', 'abc', /..../)
|
||||
|
|
@ -1,35 +1,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
|
||||
}
|
||||
|
|
|
|||
27
src/components/HighlightOnIndexes.test.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { is } from 'utils/is'
|
||||
import { HighlightOnIndexes } from './HighlightOnIndexes'
|
||||
|
||||
function test(title: string, text: string, indexes?: number[]) {
|
||||
it(title, () => {
|
||||
expect(render(<HighlightOnIndexes text={text} indexes={indexes} />).container.textContent).toBe(
|
||||
text,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const text = 'abcdef'
|
||||
for (let i = 0; i < Math.pow(2, text.length); i++) {
|
||||
const bitwise = i.toString(2)
|
||||
const indexes = bitwise
|
||||
.split('')
|
||||
.reverse()
|
||||
.map((bit, index) => (bit === '1' ? index : false))
|
||||
.filter(is.not.false)
|
||||
test(
|
||||
`renders properly when highlight ${bitwise.padStart(text.length, '0')}, indexes [${indexes}]`,
|
||||
text,
|
||||
indexes,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +1,28 @@
|
|||
import * as React from 'react'
|
||||
|
||||
export function HighlightOnIndexes(props: { text: string; indexes?: number[] }) {
|
||||
const { text, indexes } = props
|
||||
|
||||
if (!indexes?.length) return <>{text}</>
|
||||
|
||||
export function HighlightOnIndexes({ text, indexes = [] }: { text: string; indexes?: number[] }) {
|
||||
return (
|
||||
<>
|
||||
{text
|
||||
.split('')
|
||||
.map((char, i) =>
|
||||
indexes.includes(i) ? <mark key={i}>{char}</mark> : <span key={i}>{char}</span>,
|
||||
)}
|
||||
{[-1]
|
||||
.concat(indexes)
|
||||
.map((index, i, arr) => [
|
||||
index === -1 ? '' : text.slice(index, index + 1),
|
||||
text.slice(index + 1, arr[i + 1]),
|
||||
])
|
||||
.reduce((arr, pair) => {
|
||||
const last = arr[arr.length - 1]
|
||||
if (last && !last[1]) {
|
||||
last[0] += pair[0]
|
||||
last[1] += pair[1]
|
||||
} else {
|
||||
arr.push(pair)
|
||||
}
|
||||
return arr
|
||||
}, [] as string[][])
|
||||
.map(([chunk, nextChunk], i) => [
|
||||
chunk && <mark key={i * 2}>{chunk}</mark>,
|
||||
nextChunk && <span key={i * 2 + 1}>{nextChunk}</span>,
|
||||
])}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
import * as React from 'react'
|
||||
|
||||
// I tried to install `react-iifc` but that causes TS build errors for unknown reason
|
||||
// So here the duplicated code is
|
||||
|
||||
export function IIFC({ children }: { children(): React.ReactNode }) {
|
||||
return <>{children()}</>
|
||||
}
|
||||
|
|
@ -19,12 +19,11 @@ import {
|
|||
HourglassIcon as Hourglass,
|
||||
IconProps,
|
||||
MarkdownIcon as Markdown,
|
||||
OctofaceIcon as Octoface,
|
||||
PinIcon as Pin,
|
||||
ReplyIcon as Reply,
|
||||
SearchIcon as Search,
|
||||
TabIcon as Tab,
|
||||
XIcon as X
|
||||
XIcon as X,
|
||||
} from '@primer/octicons-react'
|
||||
import * as React from 'react'
|
||||
import { cx } from 'utils/cx'
|
||||
|
|
@ -42,7 +41,6 @@ const iconToComponentMap = {
|
|||
Hourglass,
|
||||
Submodule,
|
||||
Grabber,
|
||||
Octoface,
|
||||
ChevronDown,
|
||||
X,
|
||||
Gear,
|
||||
|
|
@ -63,15 +61,7 @@ const typeToIconComponentMap: {
|
|||
} = {
|
||||
search: 'Search',
|
||||
loading: 'Clock',
|
||||
hourglass: 'Hourglass',
|
||||
submodule: 'Submodule',
|
||||
grabber: 'Grabber',
|
||||
octoface: 'Octoface',
|
||||
comment: 'Comment',
|
||||
x: 'X',
|
||||
pin: 'Pin',
|
||||
tab: 'Tab',
|
||||
gear: 'Gear',
|
||||
diff: 'Diff',
|
||||
diffAdded: 'DiffAdded',
|
||||
diffIgnored: 'DiffIgnored',
|
||||
|
|
@ -79,7 +69,6 @@ const typeToIconComponentMap: {
|
|||
diffRemoved: 'DiffRemoved',
|
||||
diffRenamed: 'DiffRenamed',
|
||||
folder: 'ChevronRight',
|
||||
'chevron-down': 'ChevronDown',
|
||||
'go-to': 'Reply',
|
||||
'.zip': 'FileZip',
|
||||
'.rar': 'FileZip',
|
||||
|
|
@ -102,7 +91,9 @@ const typeToIconComponentMap: {
|
|||
}
|
||||
|
||||
type Props = {
|
||||
type: string
|
||||
type?: keyof typeof typeToIconComponentMap
|
||||
name?: keyof typeof iconToComponentMap
|
||||
IconComponent?: React.ComponentType<IconProps>
|
||||
className?: string
|
||||
placeholder?: boolean
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void
|
||||
|
|
@ -110,19 +101,15 @@ type Props = {
|
|||
|
||||
export const Icon = React.memo(function Icon({
|
||||
type,
|
||||
className = undefined,
|
||||
placeholder,
|
||||
className = undefined,
|
||||
name = (type && typeToIconComponentMap[type]) || defaultIcon,
|
||||
IconComponent = iconToComponentMap[name],
|
||||
...otherProps
|
||||
}: Props) {
|
||||
let children: React.ReactNode = null
|
||||
if (!placeholder) {
|
||||
const name = typeToIconComponentMap[type] || defaultIcon
|
||||
const IconComponent = iconToComponentMap[name]
|
||||
children = <IconComponent className={cx('octicon', name)} {...otherProps} />
|
||||
}
|
||||
return (
|
||||
<div className={cx('octicon-wrapper', className)} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
<span className={cx('octicon-wrapper', className)} {...otherProps}>
|
||||
{placeholder ? null : <IconComponent className={cx('octicon', name)} {...otherProps} />}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
49
src/components/IconButton.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { IconProps } from '@primer/octicons-react'
|
||||
import { Box, merge, SxProp, useTheme } from '@primer/react'
|
||||
import { getBaseStyles, getSizeStyles, getVariantStyles } from '@primer/react/lib/Button/styles'
|
||||
import {
|
||||
IconButtonProps as PrimerIconButtonProps,
|
||||
StyledButton,
|
||||
} from '@primer/react/lib/Button/types'
|
||||
import React from 'react'
|
||||
import { is } from 'utils/is'
|
||||
|
||||
export type IconButtonProps = PrimerIconButtonProps & {
|
||||
iconSize?: IconProps['size']
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
// Modified version of @primer/react/lib/Button/Button.tsx
|
||||
// Added better support of colors & size
|
||||
|
||||
export function IconButton(props: IconButtonProps) {
|
||||
const {
|
||||
variant = 'default',
|
||||
size = 'medium',
|
||||
iconSize, // grow the icon to the same size as the button
|
||||
iconColor, // extra control of icon color
|
||||
sx: sxProp = {},
|
||||
icon: Icon,
|
||||
...rest
|
||||
} = props
|
||||
const { theme } = useTheme()
|
||||
const sxStyles = merge.all(
|
||||
[
|
||||
getBaseStyles(theme),
|
||||
getSizeStyles(size, variant, true),
|
||||
getVariantStyles(variant, theme),
|
||||
// Unsatisfied with preset color of the `invisible` variant
|
||||
{
|
||||
color: iconColor || (variant === 'invisible' ? 'fg.subtle' : undefined),
|
||||
},
|
||||
sxProp as SxProp,
|
||||
].filter(is.not.undefined),
|
||||
)
|
||||
return (
|
||||
<StyledButton sx={sxStyles} {...rest}>
|
||||
<Box as="span" sx={{ display: 'inline-block' }}>
|
||||
<Icon size={iconSize} />
|
||||
</Box>
|
||||
</StyledButton>
|
||||
)
|
||||
}
|
||||
24
src/components/Inputs/Checkbox.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
62
src/components/Inputs/SelectInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Icon } from 'components/Icon'
|
||||
import { HourglassIcon } from '@primer/octicons-react'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
|
|
@ -8,7 +8,7 @@ export function LoadingIndicator({ text }: Props) {
|
|||
return (
|
||||
<div className={'loading-indicator-container'}>
|
||||
<div className={'loading-indicator'}>
|
||||
<Icon className={'loading-indicator-icon'} type={'hourglass'} />
|
||||
<HourglassIcon className={'loading-indicator-icon'} />
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { GrabberIcon } from '@primer/octicons-react'
|
||||
import { Icon } from 'components/Icon'
|
||||
import * as React from 'react'
|
||||
import { ResizeState, useResizeHandler } from '../utils/hooks/useResizeHandler'
|
||||
import { Size2D } from './SideBarBodyWrapper'
|
||||
import { Size2D } from './Size'
|
||||
|
||||
type Props = {
|
||||
size: Size2D
|
||||
|
|
@ -21,7 +22,7 @@ export function ResizeHandler({ onResize, onResetSize, onResizeStateChange, size
|
|||
onDoubleClick={onResetSize}
|
||||
style={style}
|
||||
>
|
||||
<Icon type={'grabber'} className={'grabber-icon'} size={20} />
|
||||
<Icon IconComponent={GrabberIcon} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
16
src/components/RoundIconButton.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import { IconButton, IconButtonProps } from './IconButton'
|
||||
|
||||
export function RoundIconButton(props: IconButtonProps) {
|
||||
return (
|
||||
<IconButton
|
||||
variant="invisible"
|
||||
title={props['aria-label']}
|
||||
{...props}
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
...props.sx,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import * as React from 'react'
|
||||
export function SelectInput<T>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
...selectProps
|
||||
}: Override<
|
||||
React.DetailedHTMLProps<React.SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement>,
|
||||
IO<T> & {
|
||||
options: Option<T>[]
|
||||
}
|
||||
>) {
|
||||
return (
|
||||
<div className={'select-wrapper'}>
|
||||
<select
|
||||
onChange={e => {
|
||||
const key = e.target.value
|
||||
const option = options.find(option => option.key === key)
|
||||
onChange(option!?.value)
|
||||
}}
|
||||
value={options.find(option => option.value === value)?.key}
|
||||
{...selectProps}
|
||||
>
|
||||
{options.map(option => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className={'chevron'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export type Option<T> = {
|
||||
key: string
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
|
@ -1,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],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
85
src/components/SideBarResizeHandler.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import * as React from 'react'
|
||||
import { useDebounce, useWindowSize } from 'react-use'
|
||||
import { getDefaultConfigs } from 'utils/config/helper'
|
||||
import * as DOMHelper from 'utils/DOMHelper'
|
||||
import { useAfterRedirect } from 'utils/hooks/useFastRedirect'
|
||||
import { ResizeHandler } from './ResizeHandler'
|
||||
import { Size, Size2D } from './Size'
|
||||
|
||||
const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100
|
||||
const MINIMAL_WIDTH = 240
|
||||
|
||||
function getSafeWidth(width: Size, windowWidth: number) {
|
||||
if (width > windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH)
|
||||
return windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH
|
||||
if (width < MINIMAL_WIDTH) return MINIMAL_WIDTH
|
||||
return width
|
||||
}
|
||||
|
||||
function useSidebarWidth() {
|
||||
const configContext = useConfigs()
|
||||
|
||||
// size data flow:
|
||||
// windowSize.width => width
|
||||
// width => config.sideBarWidth
|
||||
// width => --gitako-width // layout effect
|
||||
// resize event => width
|
||||
// resize event => --gitako-width // rAF
|
||||
const [width, setWidth] = React.useState(configContext.value.sideBarWidth)
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
React.useEffect(() => {
|
||||
const safeSize = getSafeWidth(width, windowWidth)
|
||||
if (safeSize !== width) setWidth(safeSize)
|
||||
}, [windowWidth, width])
|
||||
useDebounce(() => configContext.onChange({ sideBarWidth: width }), 100, [width])
|
||||
|
||||
React.useLayoutEffect(() => DOMHelper.setGitakoWidthCSSVariable(width), [width])
|
||||
|
||||
// Keep variable when directing from PR to repo home via meta bar
|
||||
useAfterRedirect(React.useCallback(() => DOMHelper.setGitakoWidthCSSVariable(width), [width]))
|
||||
|
||||
return [width, setWidth] as const
|
||||
}
|
||||
|
||||
export function SideBarResizeHandler({
|
||||
onResizeStateChange,
|
||||
}: Pick<React.ComponentProps<typeof ResizeHandler>, 'onResizeStateChange'>) {
|
||||
const [width, setWidth] = useSidebarWidth()
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
const onResize = React.useMemo(() => {
|
||||
let widthToApply: Size
|
||||
let pending = false
|
||||
return ([width]: Size2D) => {
|
||||
// do NOT merge this with the above similar effect
|
||||
widthToApply = width
|
||||
|
||||
if (!pending) {
|
||||
pending = true
|
||||
// Update size using useEffect would cause delay
|
||||
requestAnimationFrame(() => {
|
||||
pending = false
|
||||
widthToApply = getSafeWidth(widthToApply, windowWidth)
|
||||
DOMHelper.setGitakoWidthCSSVariable(widthToApply)
|
||||
setWidth(widthToApply)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [windowWidth, setWidth])
|
||||
|
||||
const onResetSize = React.useCallback(
|
||||
() => setWidth(getDefaultConfigs().sideBarWidth),
|
||||
[setWidth],
|
||||
)
|
||||
|
||||
const dummySize: Size2D = React.useMemo(() => [width, 0], [width])
|
||||
|
||||
return (
|
||||
<ResizeHandler
|
||||
onResize={onResize}
|
||||
onResetSize={onResetSize}
|
||||
onResizeStateChange={onResizeStateChange}
|
||||
size={dummySize}
|
||||
/>
|
||||
)
|
||||
}
|
||||
10
src/components/SidebarContext.tsx
Normal 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 },
|
||||
})
|
||||
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
export type Size = number
|
||||
export type Size2D = [Size, Size]
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import * as features from 'utils/features'
|
||||
|
||||
type Size = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type?: string | React.ComponentType
|
||||
children(size: Partial<Size>): React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLElement>
|
||||
|
||||
export function SizeObserver({ type = 'div', children, ...rest }: Props) {
|
||||
const ref = React.useRef<any>()
|
||||
|
||||
const [size, setSize] = React.useState<Partial<Size>>({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (features.resize) {
|
||||
const observer = new window.ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
const rect = entry.contentRect
|
||||
setSize(rect)
|
||||
})
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
} else {
|
||||
if ('getBoundingClientRect' in ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setSize(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const props: any = { ...rest, ref } // :)
|
||||
|
||||
return React.createElement(type, props, children(size))
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { SyncIcon } from '@primer/octicons-react'
|
||||
import iconURL from 'assets/icons/Gitako.png'
|
||||
import { useConfigs } from 'containers/ConfigsContext'
|
||||
import { ReloadContext } from 'containers/ReloadContext'
|
||||
import * as React from 'react'
|
||||
import { useDebounce, useWindowSize } from 'react-use'
|
||||
import { cx } from 'utils/cx'
|
||||
import { useResizeHandler } from 'utils/hooks/useResizeHandler'
|
||||
import { Icon } from './Icon'
|
||||
import { RoundIconButton } from './RoundIconButton'
|
||||
|
||||
type Props = {
|
||||
error?: string | null
|
||||
|
|
@ -20,6 +22,7 @@ function getSafeDistance(y: number, height: number) {
|
|||
}
|
||||
|
||||
export function ToggleShowButton({ error, className, onClick, onHover }: Props) {
|
||||
const reload = React.useContext(ReloadContext)
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
const config = useConfigs()
|
||||
const [distance, setDistance] = React.useState(config.value.toggleButtonVerticalDistance)
|
||||
|
|
@ -38,11 +41,11 @@ export function ToggleShowButton({ error, className, onClick, onHover }: Props)
|
|||
)
|
||||
|
||||
// reposition on window height change, but ignores distance change
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.style.top = distance + 'px'
|
||||
}
|
||||
}, [height])
|
||||
}, [height]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// And this repositions on drag
|
||||
const { onPointerDown } = useResizeHandler(
|
||||
|
|
@ -67,13 +70,21 @@ export function ToggleShowButton({ error, className, onClick, onHover }: Props)
|
|||
onPointerDown={onPointerDown}
|
||||
title={'Gitako (draggable)'}
|
||||
>
|
||||
{config.value.toggleButtonContent === 'octoface' ? (
|
||||
<Icon className={'octoface-icon'} type={'octoface'} />
|
||||
) : (
|
||||
<img className={'tentacle'} draggable={false} src={iconURL} />
|
||||
)}
|
||||
<img className={'tentacle'} draggable={false} src={iconURL} />
|
||||
</button>
|
||||
{error && <span className={'error-message'}>{error}</span>}
|
||||
{error && (
|
||||
<span className={'error-message'}>
|
||||
{error}
|
||||
<RoundIconButton
|
||||
sx={{ ml: 1 }}
|
||||
variant="danger"
|
||||
size="small"
|
||||
aria-label={'Reload Gitako'}
|
||||
icon={SyncIcon}
|
||||
onClick={reload}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { fuzzyMode } from './fuzzyMode'
|
||||
|
||||
type TreeNodeSource = {
|
||||
[key: string]: true | TreeNodeSource
|
||||
}
|
||||
|
||||
function createTreeNode(source: TreeNodeSource, name: string = '', paths: string[] = []): TreeNode {
|
||||
function createTreeNode(source: TreeNodeSource, name = '', paths: string[] = []): TreeNode {
|
||||
const subPaths = paths.concat(name)
|
||||
return {
|
||||
name,
|
||||
|
|
|
|||