├── .env.example ├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── dependabot.yml │ ├── release-assets.yml │ ├── tests.yml │ └── version.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .sentryclirc ├── LICENSE ├── Makefile ├── README.md ├── Safari ├── .gitignore └── Gitako │ ├── Gitako Extension │ ├── Gitako_Extension.entitlements │ ├── Info.plist │ └── SafariWebExtensionHandler.swift │ ├── Gitako.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ ├── Gitako │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Gitako-1024.png │ │ │ ├── Gitako-128.png │ │ │ ├── Gitako-16.png │ │ │ ├── Gitako-256.png │ │ │ ├── Gitako-257.png │ │ │ ├── Gitako-32.png │ │ │ ├── Gitako-33.png │ │ │ ├── Gitako-512.png │ │ │ ├── Gitako-513.png │ │ │ └── Gitako-64.png │ │ └── Contents.json │ ├── Base.lproj │ │ └── Main.storyboard │ ├── Gitako.entitlements │ ├── Info.plist │ └── ViewController.swift │ ├── GitakoTests │ ├── GitakoTests.swift │ └── Info.plist │ └── GitakoUITests │ ├── GitakoUITests.swift │ └── Info.plist ├── __tests__ ├── .eslintrc.json ├── babel.config.js ├── cases │ ├── baseline.ts │ ├── empty-project.ts │ ├── expand-to-target.ts │ ├── homepage.not-render.ts │ ├── pjax.commits-page.ts │ ├── pjax.files-page.ts │ ├── pjax.general.ts │ ├── pjax.internal.ts │ ├── project-page.gitako.ts │ └── pull-request-page.gitako.ts ├── global.d.ts ├── jest.config.js ├── jest.puppeteer.config.js ├── puppeteer.d.ts ├── selectors.ts ├── setup.ts ├── testURL.ts ├── tsconfig.json └── utils.ts ├── assets ├── Chrome.svg ├── Edge.svg └── Firefox.svg ├── babel.config.js ├── contributing.md ├── jest-puppeteer.config.js ├── jest.config.js ├── package.json ├── patches ├── @primer+behaviors+1.1.3.patch ├── pjax-api+3.44.0.patch ├── spica+0.0.781.patch └── styled-components+5.3.5.patch ├── scripts ├── get-version.js ├── post-version.sh └── vscode-icons │ ├── check-emit-dir.js │ ├── generate-file-icon-index.js │ ├── generate-folder-icon-index.js │ ├── generate-icon-index.js │ ├── language-id-ext.json │ └── resolve-languages-map.js ├── server ├── .eslintrc.json ├── .gitignore ├── api │ ├── gitee.ts │ ├── github.ts │ ├── index.ts │ ├── redirect.ts │ └── utils.ts ├── package.json ├── tsconfig.json ├── vercel.json └── yarn.lock ├── src ├── .eslintrc.json ├── analytics.ts ├── assets │ └── icons │ │ ├── Gitako-128.png │ │ ├── Gitako-256.png │ │ ├── Gitako-64.png │ │ ├── Gitako.png │ │ ├── csv.d.ts │ │ ├── file-icons-index.csv │ │ ├── folder-icons-index.csv │ │ └── png.d.ts ├── background.ts ├── common.d.ts ├── components │ ├── AccessDeniedDescription.tsx │ ├── Clippy.tsx │ ├── FileExplorer │ │ ├── DiffStatGraph.tsx │ │ ├── DiffStatText.tsx │ │ ├── Node.tsx │ │ ├── hooks │ │ │ ├── useExpandTo.tsx │ │ │ ├── useFocusNode.tsx │ │ │ ├── useGetCurrentPath.tsx │ │ │ ├── useGoTo.tsx │ │ │ ├── useHandleKeyDown.tsx │ │ │ ├── useNodeRenderers.tsx │ │ │ ├── useOnNodeClick.tsx │ │ │ ├── useOnSearch.tsx │ │ │ ├── useRenderLabelText.tsx │ │ │ ├── useToggleExpansion.tsx │ │ │ ├── useVisibleNodesGenerator.tsx │ │ │ └── useVisibleNodesGeneratorMethods.tsx │ │ ├── index.tsx │ │ ├── useHandleNodeFocus.tsx │ │ ├── useLatestValueRef.tsx │ │ ├── useVirtualScroll.tsx │ │ └── useVisibleNodes.tsx │ ├── FocusTarget.tsx │ ├── Footer.tsx │ ├── Gitako.tsx │ ├── Highlight.test.tsx │ ├── Highlight.tsx │ ├── HighlightOnIndexes.test.tsx │ ├── HighlightOnIndexes.tsx │ ├── Icon.tsx │ ├── IconButton.tsx │ ├── Inputs │ │ ├── Checkbox.tsx │ │ └── SelectInput.tsx │ ├── LoadingIndicator.tsx │ ├── MetaBar.tsx │ ├── Portal.tsx │ ├── ResizeHandler.tsx │ ├── RoundIconButton.tsx │ ├── SearchBar.tsx │ ├── SideBar.tsx │ ├── SideBarResizeHandler.tsx │ ├── SidebarContext.tsx │ ├── Size.tsx │ ├── ToggleShowButton.tsx │ ├── searchModes │ │ ├── fuzzyMode.test.ts │ │ ├── fuzzyMode.tsx │ │ ├── index.tsx │ │ └── regexMode.tsx │ └── settings │ │ ├── AccessTokenSettings.tsx │ │ ├── FileTreeSettings.tsx │ │ ├── KeyboardShortcutSetting.tsx │ │ ├── SettingsBar.tsx │ │ ├── SettingsSection.tsx │ │ ├── SidebarSettings.tsx │ │ └── SimpleConfigField │ │ ├── Checkbox.tsx │ │ ├── FieldLabel.tsx │ │ ├── SelectInput.tsx │ │ └── index.tsx ├── containers │ ├── ConfigsContext.tsx │ ├── ErrorBoundary.tsx │ ├── ErrorContext.tsx │ ├── Inspector.tsx │ ├── OAuthWrapper.tsx │ ├── PortalContext.tsx │ ├── ReloadContext.tsx │ ├── RepoContext.tsx │ ├── SideBarState.tsx │ └── Theme.tsx ├── content.scss ├── content.tsx ├── env.ts ├── firefox-shim.js ├── global.d.ts ├── manifest.json ├── platforms │ ├── GitHub │ │ ├── API.ts │ │ ├── CopyFileButton.tsx │ │ ├── DOMHelper.ts │ │ ├── Request.d.ts │ │ ├── URLHelper.ts │ │ ├── embeddedDataStructures.ts │ │ ├── getCommitTreeData.ts │ │ ├── getPullRequestTreeData.ts │ │ ├── hooks │ │ │ ├── useEnterpriseStatBarStyleFix.ts │ │ │ ├── useGitHubAttachCopySnippetButton.ts │ │ │ └── useGitHubCodeFold.tsx │ │ ├── index.ts │ │ └── utils.ts │ ├── Gitea │ │ ├── API.ts │ │ ├── DOMHelper.ts │ │ ├── Request.d.ts │ │ ├── URLHelper.ts │ │ └── index.ts │ ├── Gitee │ │ ├── API.ts │ │ ├── DOMHelper.ts │ │ ├── Request.d.ts │ │ ├── URLHelper.ts │ │ └── index.ts │ ├── dummyPlatformForTypeSafety.ts │ ├── index.ts │ └── platform.d.ts ├── react-override.d.ts ├── styles │ ├── clippy.scss │ ├── code-folding.scss │ ├── gitee.scss │ ├── github.scss │ ├── index.scss │ ├── keyframes.scss │ ├── layout.scss │ ├── primer-like.scss │ └── themes.scss └── utils │ ├── $.ts │ ├── DOMHelper.ts │ ├── EventHub.ts │ ├── URLHelper.ts │ ├── VisibleNodesGenerator │ ├── BaseLayer.ts │ ├── CompressLayer.ts │ ├── FlattenLayer.ts │ ├── ShakeLayer.ts │ ├── VisibleNodesGenerator.test.ts │ ├── index.ts │ ├── prepare.ts │ ├── searchResults │ │ └── json.json │ └── treeData.json │ ├── assert.ts │ ├── config │ ├── helper.ts │ └── migrations │ │ ├── 1.0.1.ts │ │ ├── 1.3.4.ts │ │ ├── 2.6.0.ts │ │ ├── 3.0.0.ts │ │ ├── 3.13.1.ts │ │ ├── 3.5.0.ts │ │ ├── clearRaiseErrorCache.ts │ │ └── index.ts │ ├── createAnchorClickHandler.ts │ ├── cx.ts │ ├── features.ts │ ├── general.test.ts │ ├── general.ts │ ├── getSafeWidth.test.ts │ ├── getSafeWidth.ts │ ├── gitSubmodule.ts │ ├── hooks │ ├── useAbortableEffect.ts │ ├── useCSSVariable.ts │ ├── useConditionalHook.ts │ ├── useEffectOnSerializableUpdates.ts │ ├── useElementSize.ts │ ├── useFastRedirect.ts │ ├── useHandleNetworkError.ts │ ├── useLoadedContext.ts │ ├── useOnLocationChange.ts │ ├── useOnShortcutPressed.tsx │ ├── useProgressBar.ts │ ├── useResizeHandler.ts │ ├── useStateIO.ts │ └── useUpdateReason.ts │ ├── is.ts │ ├── keyHelper.ts │ ├── networkService.ts │ ├── parseIconMapCSV.ts │ ├── storageHelper.ts │ ├── treeParser.ts │ └── waitForNextEvent.ts ├── tsconfig.json ├── webpack.config.ts └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | GITHUB_OAUTH_CLIENT_ID=GITHUB_OAUTH_CLIENT_ID 2 | GITHUB_OAUTH_CLIENT_SECRET=GITHUB_OAUTH_CLIENT_SECRET 3 | 4 | SENTRY_PUBLIC_KEY=SENTRY_PUBLIC_KEY 5 | SENTRY_PROJECT_ID=SENTRY_PROJECT_ID 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "es2022": true 5 | }, 6 | "overrides": [ 7 | { 8 | "files": ["webpack.config.ts", "*.tsx?"], 9 | "excludedFiles": ["*.d.ts"], 10 | "plugins": ["@typescript-eslint"], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "sourceType": "module" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: enixcoda 4 | patreon: # enixcoda 5 | open_collective: # enixcoda 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Generate build for reuse 2 | on: 3 | workflow_call: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Get yarn cache directory path 13 | id: yarn-cache-dir-path 14 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 15 | 16 | - uses: actions/cache@v3 17 | id: yarn-cache 18 | with: 19 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 20 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | ${{ runner.os }}-yarn- 23 | 24 | - name: Install Dependencies 25 | env: 26 | PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true 27 | run: | 28 | yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline 29 | 30 | - name: Retrieve vscode icons 31 | uses: actions/checkout@v3 32 | with: 33 | repository: 'vscode-icons/vscode-icons' 34 | path: 'vscode-icons' 35 | 36 | - name: Build 37 | run: | 38 | NODE_OPTIONS=--openssl-legacy-provider make build 39 | 40 | - name: Archive production artifacts 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: dist 44 | path: dist 45 | -------------------------------------------------------------------------------- /.github/workflows/release-assets.yml: -------------------------------------------------------------------------------- 1 | name: Release assets when push tags 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.* # roughly matching version numbers 7 | - release-* 8 | 9 | jobs: 10 | build: 11 | uses: ./.github/workflows/build.yml 12 | 13 | version: 14 | uses: ./.github/workflows/version.yml 15 | 16 | release: 17 | needs: 18 | - build 19 | - version 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - name: Download a built dist artifact 27 | uses: actions/download-artifact@v4 28 | with: 29 | name: dist 30 | path: dist 31 | 32 | - name: Create Release 33 | id: create_release 34 | uses: actions/create-release@latest 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | with: 38 | tag_name: ${{ needs.version.outputs.VERSION }} 39 | release_name: ${{ needs.version.outputs.VERSION }} 40 | draft: false 41 | prerelease: false 42 | 43 | - name: Generate release zip 44 | run: | 45 | make compress 46 | 47 | - name: Upload Release Asset 48 | id: upload-release-asset 49 | uses: actions/upload-release-asset@v1 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | with: 53 | upload_url: ${{ steps.create_release.outputs.upload_url }} 54 | asset_path: ./dist/Gitako.zip 55 | asset_name: Gitako.zip 56 | asset_content_type: application/zip 57 | -------------------------------------------------------------------------------- /.github/workflows/version.yml: -------------------------------------------------------------------------------- 1 | name: Get VERSION for referencing 2 | on: 3 | workflow_call: 4 | outputs: 5 | VERSION: 6 | description: "The VERSION string" 7 | value: ${{ jobs.version.outputs.VERSION }} 8 | 9 | jobs: 10 | version: 11 | runs-on: ubuntu-latest 12 | 13 | outputs: 14 | VERSION: ${{ steps.get_ref.outputs.VERSION }} 15 | 16 | steps: 17 | - name: Get the ref 18 | id: get_ref 19 | run: echo ::set-output name=VERSION::$(echo $GITHUB_REF | cut -d / -f 3) 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .env 3 | node_modules 4 | tmp 5 | dist 6 | dist-firefox 7 | yarn-error.log 8 | /vscode-icons 9 | firefox-profile 10 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint-staged --quiet 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *-profile/ 2 | dist/ 3 | vscode-icons/ 4 | Safari 5 | -------------------------------------------------------------------------------- /.sentryclirc: -------------------------------------------------------------------------------- 1 | [defaults] 2 | org = enix 3 | project = gitako 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 EnixCoda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RAW_VERSION?=$(shell node scripts/get-version.js) 2 | FULL_VERSION=v$(RAW_VERSION) 3 | 4 | pull-icons: 5 | git clone https://github.com/vscode-icons/vscode-icons.git vscode-icons --depth=1 6 | 7 | update-icons: 8 | cd vscode-icons && git pull 9 | node scripts/vscode-icons/resolve-languages-map 10 | node scripts/vscode-icons/generate-icon-index 11 | 12 | build: 13 | yarn build 14 | 15 | build-all: 16 | yarn build:all 17 | 18 | test: 19 | yarn test 20 | 21 | upload-for-analytics: 22 | # make sure sentry can retrieve current commit on remote, push both branch and tag 23 | git push 24 | git push --tags 25 | yarn sentry-cli releases new "$(FULL_VERSION)" 26 | yarn sentry-cli releases set-commits "$(FULL_VERSION)" --auto 27 | yarn sentry-cli releases files "$(FULL_VERSION)" upload-sourcemaps dist --no-rewrite 28 | yarn sentry-cli releases finalize "$(FULL_VERSION)" 29 | 30 | compress: 31 | cd dist && zip -r Gitako-$(FULL_VERSION).zip * -x *.map -x *.DS_Store -x *.zip 32 | 33 | compress-firefox: 34 | cd dist-firefox && zip -r Gitako-$(FULL_VERSION)-firefox.zip * -x *.map -x *.DS_Store -x *.zip 35 | 36 | compress-source: 37 | git archive -o dist/Gitako-$(FULL_VERSION)-source.zip HEAD 38 | zip dist/Gitako-$(FULL_VERSION)-source.zip .env 39 | zip -r dist/Gitako-$(FULL_VERSION)-source.zip vscode-icons/icons 40 | 41 | release: 42 | $(MAKE) build-all 43 | $(MAKE) test 44 | $(MAKE) upload-for-analytics 45 | $(MAKE) compress 46 | $(MAKE) compress-firefox 47 | $(MAKE) compress-source 48 | 49 | release-dry-run: 50 | $(MAKE) build-all 51 | $(MAKE) test 52 | $(MAKE) compress 53 | $(MAKE) compress-firefox 54 | $(MAKE) compress-source 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gitako 2 | 3 |

4 | 5 |

6 | 7 | Gitako is a free file tree extension for GitHub, available on Chrome, Firefox and Edge. 8 | 9 | ### Features 10 | 11 | - 📂 File tree for repository and pull request 12 | - 🔎 Instant file search and navigation 13 | - 🕶️ Support private repositories 14 | - 🧩 Support [GitHub enterprise](https://github.com/EnixCoda/Gitako/wiki/Use-in-GitHub-enterprise-and-other-sites), Gitea, Gitee, and [more](https://github.com/EnixCoda/Gitako/issues/60) 15 | - 🏎 Always performant, even in gigantic projects 16 | - ⌨️ Intuitive keyboard navigation 17 | - 📋 Copy snippets and file content 18 | - 🎨 Various icons and official themes support 19 | - 🗂 Support git submodule 20 | - 📏 Fold source code 21 | 22 | ### Install 23 | 24 | Chrome 25 | Edge 26 | Firefox 27 | 28 | It is more recommended for Edge users to install from Chrome store. It may delay for weeks before updates got published to Edge store because its review process is slow. 29 | 30 | ### Help Gitako 31 | 32 | Gitako is **FREE**. If you like it, please 33 | 34 | - ⭐️ Star it at GitHub 35 | - 👍 Review in the extension store 36 | 37 | [Feature discussions](https://github.com/EnixCoda/Gitako/discussions) and [bug reports](https://github.com/EnixCoda/Gitako/issues/) are also welcome! 38 | 39 | Check out [contributing.md](./contributing.md) if you want to contribute to Gitako directly. 40 | 41 | ### About 42 | 43 | #### Source of the name and logo? 44 | 45 | The totem of GitHub is a cute octopus. And octopus in Japanese is `タコ`(tako). 46 | Then concat them together: 47 | 48 | git + tako -> gitako 49 | 50 | The logo of Gitako is a tentacle of octopus, indicates that Gitako works like a part of GitHub. 51 | -------------------------------------------------------------------------------- /Safari/.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Gcc Patch 26 | /*.gcno 27 | 28 | Resources 29 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako Extension/Gitako_Extension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Gitako Extension 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSExtension 26 | 27 | NSExtensionPointIdentifier 28 | com.apple.Safari.web-extension 29 | NSExtensionPrincipalClass 30 | $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako Extension/SafariWebExtensionHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariWebExtensionHandler.swift 3 | // Gitako Extension 4 | // 5 | // Created by Enix on 13/6/2021. 6 | // 7 | 8 | import SafariServices 9 | import os.log 10 | 11 | let SFExtensionMessageKey = "message" 12 | 13 | class SafariWebExtensionHandler: NSObject { 14 | } 15 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Gitako 4 | // 5 | // Created by Enix on 13/6/2021. 6 | // 7 | 8 | import Cocoa 9 | 10 | @main 11 | class AppDelegate: NSObject, NSApplicationDelegate { 12 | 13 | func applicationDidFinishLaunching(_ notification: Notification) { 14 | // Insert code here to initialize your application 15 | } 16 | 17 | func applicationWillTerminate(_ notification: Notification) { 18 | // Insert code here to tear down your application 19 | } 20 | 21 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 22 | return true 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Gitako-16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "Gitako-32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "Gitako-33.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "Gitako-64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "Gitako-128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "Gitako-257.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "Gitako-256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "Gitako-513.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "Gitako-512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "Gitako-1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-1024.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-128.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-16.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-256.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-257.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-257.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-32.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-33.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-33.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-512.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-513.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-513.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/Safari/Gitako/Gitako/Assets.xcassets/AppIcon.appiconset/Gitako-64.png -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Gitako.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSApplicationCategoryType 24 | public.app-category.productivity 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSMainStoryboardFile 28 | Main 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /Safari/Gitako/Gitako/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Gitako 4 | // 5 | // Created by Enix on 13/6/2021. 6 | // 7 | 8 | import Cocoa 9 | import SafariServices.SFSafariApplication 10 | import SafariServices.SFSafariExtensionManager 11 | 12 | let appName = "Gitako" 13 | let extensionBundleIdentifier = "enixcoda.Gitako.Extension" 14 | 15 | class ViewController: NSViewController { 16 | 17 | @IBOutlet var appNameLabel: NSTextField! 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | self.appNameLabel.stringValue = appName 22 | SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in 23 | guard let state = state, error == nil else { 24 | // Insert code to inform the user that something went wrong. 25 | return 26 | } 27 | 28 | DispatchQueue.main.async { 29 | if (state.isEnabled) { 30 | self.appNameLabel.stringValue = "\(appName)'s extension is currently on.\nYou can close this window now." 31 | } else { 32 | self.appNameLabel.stringValue = "\(appName)'s extension is currently off.\nYou can turn it on in Safari Extensions preferences.\nNote: the button below may not work, then please open preferences manually." 33 | } 34 | } 35 | } 36 | } 37 | 38 | @IBAction func openSafariExtensionPreferences(_ sender: AnyObject?) { 39 | SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in 40 | guard error == nil else { 41 | // Insert code to inform the user that something went wrong. 42 | return 43 | } 44 | 45 | DispatchQueue.main.async { 46 | NSApplication.shared.terminate(nil) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Safari/Gitako/GitakoTests/GitakoTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitakoTests.swift 3 | // GitakoTests 4 | // 5 | // Created by Enix on 13/6/2021. 6 | // 7 | 8 | import XCTest 9 | @testable import Gitako 10 | 11 | class GitakoTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Safari/Gitako/GitakoTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Safari/Gitako/GitakoUITests/GitakoUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitakoUITests.swift 3 | // GitakoUITests 4 | // 5 | // Created by Enix on 13/6/2021. 6 | // 7 | 8 | import XCTest 9 | 10 | class GitakoUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Safari/Gitako/GitakoUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /__tests__/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | }, 5 | "overrides": [ 6 | { 7 | "files": ["*.ts"], 8 | "excludedFiles": ["*.d.ts"], 9 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /__tests__/babel.config.js: -------------------------------------------------------------------------------- 1 | // TS files cannot be transformed without this babel config 2 | module.exports = require('../babel.config') 3 | -------------------------------------------------------------------------------- /__tests__/cases/baseline.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Confirm basic behaviors of puppeteer assertions 3 | */ 4 | 5 | import { testURL } from '../testURL' 6 | import { expectToFind, expectToNotFind, expectToReject } from '../utils' 7 | 8 | describe(`in random page`, () => { 9 | beforeAll(() => page.goto(testURL`https://google.com`)) 10 | 11 | it('wait for hidden non-exist element should resolve null', async () => { 12 | expect( 13 | await page.waitForSelector('.non-exist-element', { hidden: true, timeout: 1000 }), 14 | ).toBeNull() 15 | }) 16 | 17 | it('wait for exist element', async () => { 18 | expectToFind('*') 19 | }) 20 | 21 | it('wait for exist element', async () => { 22 | expectToNotFind('.non-exist-element') 23 | }) 24 | 25 | it('wait for non-exist element reject should throw', async () => { 26 | await expectToReject(page.waitForSelector('.non-exist-element', { timeout: 1000 })) 27 | }) 28 | 29 | // Cases below are expected to fail to show how async test works 30 | 31 | // // This is expected to fail! 32 | // it('wait for non-exist element reject should not throw', async () => { 33 | // await expect( 34 | // page.waitForSelector('.non-exist-element', { timeout: 1000 }), 35 | // ).rejects.not.toThrow() 36 | // }) 37 | 38 | // // This is expected to fail! 39 | // it('wait for non-exist element should resolve throw', async () => { 40 | // await expect(page.waitForSelector('.non-exist-element', { timeout: 1000 })).resolves.toThrow() 41 | // }) 42 | 43 | // // This is expected to fail! 44 | // it('wait for non-exist element should resolve not throw', async () => { 45 | // await expect( 46 | // page.waitForSelector('.non-exist-element', { timeout: 1000 }), 47 | // ).resolves.not.toThrow() 48 | // }) 49 | }) 50 | -------------------------------------------------------------------------------- /__tests__/cases/empty-project.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from '../selectors' 2 | import { testURL } from '../testURL' 3 | import { getTextContent, sleep } from '../utils' 4 | 5 | describe(`in Gitako project page`, () => { 6 | beforeAll(() => page.goto(testURL`https://github.com/GitakoExtension/test-empty`)) 7 | 8 | it('should render error message', async () => { 9 | await sleep(5000) 10 | 11 | expect(await getTextContent(selectors.gitako.errorMessage)).toBe( 12 | 'This project seems to be empty.', 13 | ) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /__tests__/cases/expand-to-target.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from '../selectors' 2 | import { testURL } from '../testURL' 3 | import { expectToFind, sleep, waitForRedirect } from '../utils' 4 | 5 | describe(`in Gitako project page`, () => { 6 | beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)) 7 | 8 | it('expand to target on load and after redirect', async () => { 9 | await sleep(3000) 10 | 11 | // Expect Gitako sidebar to have expanded src to see contents 12 | await expectToFind(selectors.gitako.fileItemOf('src/components')) 13 | 14 | await page.click(selectors.github.fileListItemLinkOf('components')) 15 | await waitForRedirect() 16 | 17 | // Expect Gitako sidebar to have expanded components and see contents 18 | await expectToFind(selectors.gitako.fileItemOf('src/components/Gitako.tsx')) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /__tests__/cases/homepage.not-render.ts: -------------------------------------------------------------------------------- 1 | import { testURL } from '../testURL' 2 | import { expectToNotFind } from '../utils' 3 | 4 | describe(`in GitHub homepage`, () => { 5 | beforeAll(() => page.goto(testURL`https://github.com`)) 6 | 7 | it('should not render Gitako', async () => { 8 | await expectToNotFind('.gitako-side-bar .gitako-side-bar-body-wrapper') 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /__tests__/cases/pjax.commits-page.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from '../selectors' 2 | import { testURL } from '../testURL' 3 | import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../utils' 4 | 5 | jest.retryTimes(3) 6 | 7 | describe(`in Gitako project page`, () => { 8 | beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/commits/develop`)) 9 | 10 | it('should not break go back in history', async () => { 11 | for (let i = 0; i < 3; i++) { 12 | const commitLinks = await page.$$(selectors.github.commitLinks) 13 | if (commitLinks.length < 2) throw new Error(`No enough commits`) 14 | commitLinks[i].click() 15 | await waitForRedirect() 16 | await expectToFind(selectors.github.commitPage) 17 | await sleep(1000) 18 | 19 | page.goBack() 20 | await sleep(1000) 21 | // The selector for file content 22 | await expectToNotFind(selectors.github.commitPage) 23 | } 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /__tests__/cases/pjax.files-page.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from '../selectors' 2 | import { testURL } from '../testURL' 3 | import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../utils' 4 | 5 | jest.retryTimes(3) 6 | 7 | describe(`in Gitako project page`, () => { 8 | beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)) 9 | 10 | it('should not break go back in history', async () => { 11 | for (let i = 0; i < 3; i++) { 12 | const fileItems = await page.$$(selectors.github.fileListItemFileLinks) 13 | if (fileItems.length < 2) throw new Error(`No enough files`) 14 | 15 | await waitForRedirect(async () => { 16 | await fileItems[i].click() 17 | }) 18 | await expectToFind(selectors.github.fileContent) 19 | await sleep(1000) 20 | 21 | page.goBack() 22 | await sleep(1000) 23 | // The selector for file content 24 | 25 | await expectToNotFind(selectors.github.fileContent) 26 | } 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /__tests__/cases/pjax.general.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from '../selectors' 2 | import { testURL } from '../testURL' 3 | import { 4 | collapseFloatModeSidebar, 5 | expandFloatModeSidebar, 6 | getTextContent, 7 | patientClick, 8 | sleep, 9 | waitForRedirect, 10 | } from '../utils' 11 | 12 | jest.retryTimes(3) 13 | 14 | describe(`in Gitako project page`, () => { 15 | beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)) 16 | 17 | it('should work with PJAX', async () => { 18 | await sleep(3000) 19 | 20 | await expandFloatModeSidebar() 21 | await patientClick(selectors.gitako.fileItemOf('src/analytics.ts')) 22 | await waitForRedirect() 23 | await collapseFloatModeSidebar() 24 | 25 | await page.click(selectors.github.navBarItemIssues) 26 | await waitForRedirect() 27 | 28 | await page.click(selectors.github.navBarItemPulls) 29 | await waitForRedirect() 30 | 31 | page.goBack() 32 | await sleep(1000) 33 | 34 | page.goBack() 35 | await sleep(1000) 36 | 37 | expect(await getTextContent(selectors.github.breadcrumbFileName)).toBe('/analytics.ts') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /__tests__/cases/pjax.internal.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from '../selectors' 2 | import { testURL } from '../testURL' 3 | import { 4 | expandFloatModeSidebar, 5 | expectToFind, 6 | expectToNotFind, 7 | patientClick, 8 | sleep, 9 | waitForRedirect, 10 | } from '../utils' 11 | 12 | jest.retryTimes(3) 13 | 14 | describe(`in Gitako project page`, () => { 15 | beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/test/multiple-changes`)) 16 | 17 | it('should work with PJAX', async () => { 18 | await sleep(3000) 19 | 20 | await expandFloatModeSidebar() 21 | await patientClick(selectors.gitako.fileItemOf('.babelrc')) 22 | await waitForRedirect() 23 | 24 | await expectToFind(selectors.github.fileContent) 25 | 26 | await waitForRedirect(async () => { 27 | await sleep(1000) // This prevents failing in some cases due to some mystery scheduling issue of puppeteer or jest 28 | page.goBack() 29 | }) 30 | 31 | // The selector for file content 32 | await expectToNotFind(selectors.github.fileContent) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /__tests__/cases/project-page.gitako.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from '../selectors' 2 | import { testURL } from '../testURL' 3 | import { expandFloatModeSidebar, expectToFind, expectToNotFind, scroll } from '../utils' 4 | 5 | jest.retryTimes(3) 6 | 7 | describe(`in Gitako project page`, () => { 8 | beforeAll(() => 9 | page.goto( 10 | testURL`https://github.com/EnixCoda/Gitako/tree/test/200-changed-files-200-lines-each`, 11 | ), 12 | ) 13 | 14 | it('should render Gitako', async () => { 15 | await expectToFind(selectors.gitako.bodyWrapper) 16 | }) 17 | 18 | it('should render file list', async () => { 19 | await expectToFind(selectors.gitako.fileItem) 20 | }) 21 | 22 | it('should render while scroll', async () => { 23 | await expandFloatModeSidebar() 24 | 25 | const filesEle = await page.waitForSelector(selectors.gitako.files) 26 | // node of tsconfig.json should NOT be rendered before scroll down 27 | await expectToNotFind(selectors.gitako.fileItemOf('tsconfig.json')) 28 | const box = await filesEle?.boundingBox() 29 | if (box) { 30 | await page.mouse.move(box.x + 40, box.y + 40) 31 | await scroll({ totalDistance: 10000, stepDistance: 100 }) 32 | 33 | // node of tsconfig.json should be rendered now 34 | await expectToFind(selectors.gitako.fileItemOf('tsconfig.json')) 35 | } 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /__tests__/cases/pull-request-page.gitako.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from '../selectors' 2 | import { testURL } from '../testURL' 3 | import { expectToFind } from '../utils' 4 | 5 | describe(`in Gitako project page`, () => { 6 | beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/pull/71`)) 7 | 8 | it('should render Gitako', async () => { 9 | await expectToFind(selectors.gitako.bodyWrapper) 10 | }) 11 | 12 | it('should render file list', async () => { 13 | await expectToFind(selectors.gitako.fileItem) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /__tests__/global.d.ts: -------------------------------------------------------------------------------- 1 | declare var page: Puppeteer.Page 2 | -------------------------------------------------------------------------------- /__tests__/jest.puppeteer.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./jest.config') 2 | 3 | module.exports = { 4 | ...baseConfig, 5 | testMatch: [...baseConfig.testMatch, '**/__tests__/cases/**/*.ts?(x)'], 6 | setupFilesAfterEnv: ['/setup.ts'], 7 | } 8 | -------------------------------------------------------------------------------- /__tests__/puppeteer.d.ts: -------------------------------------------------------------------------------- 1 | import * as Puppeteer from 'puppeteer' 2 | 3 | export as namespace Puppeteer 4 | export = Puppeteer 5 | -------------------------------------------------------------------------------- /__tests__/selectors.ts: -------------------------------------------------------------------------------- 1 | export const selectors = { 2 | github: { 3 | breadcrumbFileName: `[data-testid="breadcrumbs-filename"]`, 4 | fileContent: 'textarea[aria-label="file content"]', 5 | commitLinks: [ 6 | `li[data-testid="commit-row-item"] [data-testid="list-view-item-title-container"] a[href*="/commit/"]`, 7 | `li[data-testid="commit-row-item"] h4 a[href*="/commit/"]`, 8 | ].join(), 9 | // assume title contains `.` is file item 10 | fileListItemFileLinks: `table[aria-labelledby="folders-and-files"] tr.react-directory-row td.react-directory-row-name-cell-large-screen .react-directory-filename-column .react-directory-truncate a[aria-label$="(File)"]`, 11 | fileListItemLinkOf: (name: string) => 12 | `table[aria-labelledby="folders-and-files"] tr.react-directory-row td.react-directory-row-name-cell-large-screen .react-directory-filename-column .react-directory-truncate a[title="${name}"]`, 13 | commitPage: ['div.commit', '#diff-content-parent'].join(), 14 | navBarItemIssues: 'a[data-selected-links^="repo_issues "]', 15 | navBarItemPulls: 'a[data-selected-links^="repo_pulls "]', 16 | }, 17 | gitako: { 18 | fileItem: '.gitako-side-bar .files .node-item', 19 | fileItemOf: (path: string) => `.gitako-side-bar .files .node-item[title="${path}"]`, 20 | errorMessage: '#gitako-logo-mount-point .error-message', 21 | files: '.gitako-side-bar .files', 22 | bodyWrapper: '.gitako-side-bar .gitako-side-bar-body-wrapper', 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | jest.retryTimes(3) 2 | -------------------------------------------------------------------------------- /__tests__/testURL.ts: -------------------------------------------------------------------------------- 1 | // string template function, take input URL string and add a search param 2 | // example: url`http://g.com` => `http://g.com?k1="json-value"` 3 | export function testURL(strings: TemplateStringsArray, ...values: unknown[]) { 4 | const raw = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '') 5 | const GITAKO_ACCESS_TOKEN = process.env.GITAKO_ACCESS_TOKEN 6 | if (!GITAKO_ACCESS_TOKEN) return raw 7 | 8 | const url = new URL(raw, 'http://localhost') 9 | url.searchParams.set('gitako-config-accessToken', JSON.stringify(GITAKO_ACCESS_TOKEN)) 10 | return url.href 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."], 4 | "exclude": ["tsconfig.json"], 5 | "compilerOptions": { 6 | "target": "ES5", 7 | "module": "CommonJS", 8 | "baseUrl": null 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // `.babelrc` is not loaded by babel-loader for files under node_modules, but `babel.config.js` is 2 | module.exports = { 3 | env: { 4 | test: { 5 | plugins: ['babel-plugin-transform-es2015-modules-commonjs'], 6 | }, 7 | }, 8 | presets: [ 9 | [ 10 | '@babel/preset-env', 11 | { 12 | modules: false, 13 | targets: { 14 | esmodules: true, 15 | }, 16 | exclude: [ 17 | '@babel/plugin-transform-async-to-generator', 18 | '@babel/plugin-proposal-object-rest-spread', 19 | ], 20 | }, 21 | ], 22 | '@babel/preset-typescript', 23 | '@babel/preset-react', 24 | ], 25 | plugins: ['@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-class-properties'], 26 | } 27 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | if (process.arch === 'arm64' && process.platform === 'darwin') { 3 | require('dotenv').config() 4 | } 5 | 6 | const CRX_PATH = path.resolve(__dirname, 'dist') 7 | 8 | module.exports = { 9 | launch: { 10 | // set by mujo-code/puppeteer-headful on GitHub actions 11 | // also for usages on ARM chip Mac 12 | executablePath: process.env.PUPPETEER_EXEC_PATH, 13 | // required for enabling extensions 14 | headless: false, 15 | args: [ 16 | `--no-sandbox`, 17 | `--disable-extensions-except=${CRX_PATH}`, 18 | `--load-extension=${CRX_PATH}`, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /patches/@primer+behaviors+1.1.3.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@primer/behaviors/dist/esm/anchored-position.js b/node_modules/@primer/behaviors/dist/esm/anchored-position.js 2 | index 3729dce..0f3cfc0 100644 3 | --- a/node_modules/@primer/behaviors/dist/esm/anchored-position.js 4 | +++ b/node_modules/@primer/behaviors/dist/esm/anchored-position.js 5 | @@ -34,6 +34,9 @@ function getPositionedParent(element) { 6 | function getClippingRect(element) { 7 | let parentNode = element; 8 | while (parentNode !== null) { 9 | + if (parentNode === document) { 10 | + break 11 | + } 12 | if (parentNode === document.body) { 13 | break; 14 | } 15 | @@ -43,7 +46,7 @@ function getClippingRect(element) { 16 | } 17 | parentNode = parentNode.parentNode; 18 | } 19 | - const clippingNode = parentNode === document.body || !(parentNode instanceof HTMLElement) ? document.body : parentNode; 20 | + const clippingNode = parentNode === document ? document.documentElement : parentNode === document.body || !(parentNode instanceof HTMLElement) ? document.body : parentNode; 21 | const elemRect = clippingNode.getBoundingClientRect(); 22 | const elemStyle = getComputedStyle(clippingNode); 23 | const [borderTop, borderLeft, borderRight, borderBottom] = [ 24 | -------------------------------------------------------------------------------- /patches/pjax-api+3.44.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/pjax-api/dist/index.js b/node_modules/pjax-api/dist/index.js 2 | index bafa42e..9a124a2 100644 3 | --- a/node_modules/pjax-api/dist/index.js 4 | +++ b/node_modules/pjax-api/dist/index.js 5 | @@ -5759,14 +5759,7 @@ function loadPosition() { 6 | } 7 | exports.loadPosition = loadPosition; 8 | function savePosition() { 9 | - window.history.replaceState({ 10 | - ...window.history.state, 11 | - position: { 12 | - ...window.history.state?.position, 13 | - top: window.scrollY, 14 | - left: window.scrollX 15 | - } 16 | - }, document.title); 17 | + return; 18 | } 19 | exports.savePosition = savePosition; 20 | function isTransitable(state) { 21 | @@ -6100,7 +6093,7 @@ class Response { 22 | this.url = url; 23 | this.xhr = xhr; 24 | this.header = name => this.xhr.getResponseHeader(name); 25 | - this.document = this.xhr.responseXML.cloneNode(true); 26 | + this.document = this.xhr.responseXML; 27 | if (url.origin !== new url_1.URL(xhr.responseURL, window.location.href).origin) throw new Error(`Redirected to another origin`); 28 | Object.defineProperty(this.document, 'URL', { 29 | configurable: true, 30 | -------------------------------------------------------------------------------- /patches/spica+0.0.781.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/spica/type.ts b/node_modules/spica/type.ts 2 | index c11e7a7..969a841 100644 3 | --- a/node_modules/spica/type.ts 4 | +++ b/node_modules/spica/type.ts 5 | @@ -1,4 +1,4 @@ 6 | -import { isArray, toString, ObjectGetPrototypeOf } from './alias'; 7 | +import { ObjectGetPrototypeOf, isArray, toString } from './alias'; 8 | 9 | export type NonNull = {}; 10 | type Falsy = undefined | false | 0 | 0n | '' | null | void; 11 | @@ -238,9 +238,10 @@ export function type(value: unknown): string { 12 | case 'function': 13 | return 'Function'; 14 | case 'object': 15 | - if (value === null) return 'null'; 16 | - assert(value = value as object); 17 | - const tag: string = (value as object)[Symbol.toStringTag]; 18 | + if (value === null || value === undefined) return 'null'; 19 | + const tag: string = (value as { 20 | + [key in typeof Symbol.toStringTag]: string 21 | + })[Symbol.toStringTag]; 22 | if (tag) return tag; 23 | switch (ObjectGetPrototypeOf(value)) { 24 | case ArrayPrototype: 25 | -------------------------------------------------------------------------------- /scripts/get-version.js: -------------------------------------------------------------------------------- 1 | const { version } = require('../package.json') 2 | console.log(version) 3 | module.exports = version 4 | -------------------------------------------------------------------------------- /scripts/post-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # exit on error 3 | set -e 4 | 5 | # get current version 6 | version=$(node scripts/get-version.js) 7 | 8 | # remove git tag 9 | git tag -d v$version 10 | 11 | # update Safari version 12 | sed -i '' -E 's/MARKETING_VERSION = .*;/MARKETING_VERSION = $(RAW_VERSION);/' Safari/Gitako/Gitako.xcodeproj/project.pbxproj 13 | 14 | # merge to previous git 15 | git add . 16 | git commit --amend --no-edit 17 | 18 | # add git tag 19 | git tag v$version 20 | -------------------------------------------------------------------------------- /scripts/vscode-icons/check-emit-dir.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs').promises 3 | 4 | const emitDirPath = path.resolve(__dirname, 'tmp') 5 | exports.emitDirPath = emitDirPath 6 | 7 | async function checkEmitDir() { 8 | try { 9 | await fs.mkdir(emitDirPath) 10 | } catch (err) { 11 | await fs.stat(emitDirPath) 12 | } 13 | } 14 | exports.checkEmitDir = checkEmitDir 15 | -------------------------------------------------------------------------------- /scripts/vscode-icons/generate-file-icon-index.js: -------------------------------------------------------------------------------- 1 | const { languages } = require('./tmp/languages') 2 | const fileName = 'file-icons-index' 3 | 4 | const link = 'https://github.com/vscode-icons/vscode-icons/wiki/ListOfFiles' 5 | 6 | function parsePageContent() { 7 | const records = [] 8 | document.body 9 | .querySelector('table') 10 | .querySelectorAll('tbody tr') 11 | .forEach(tr => { 12 | const [name, id, dark, light] = Array.from(tr.querySelectorAll('td')) 13 | const exts = [] 14 | const ids = [] 15 | const names = [] 16 | 17 | id.querySelector('sub') 18 | .innerHTML.split(', ') 19 | .map(part => { 20 | const tags = part.match(/<(\w+)>(.*?)<\/\1>/g) 21 | if (tags) { 22 | tags.forEach(subPart => { 23 | const match = subPart.match(/<(\w+)>(.*?)<\/\1>/) 24 | if (match) { 25 | const [, tag, content] = match 26 | if (tag === 'strong') { 27 | // filenames 28 | names.push(content.toLowerCase()) 29 | } else if (tag === 'code') { 30 | // language ids 31 | ids.push(content) 32 | } else { 33 | console.warn(`Found unrecognized format`, subPart, tag) // unknown 34 | } 35 | } 36 | }) 37 | } else if (part) { 38 | // extensions 39 | exts.push(part.toLowerCase()) 40 | } 41 | }) 42 | records.push({ 43 | name: name.innerText, 44 | names, 45 | exts, 46 | ids, 47 | icon: getSrc(dark.querySelector('img')) || getSrc(light.querySelector('img')), 48 | }) 49 | }) 50 | return records 51 | 52 | function getSrc(img) { 53 | return img && img.src 54 | } 55 | } 56 | 57 | function prepareCSV({ name, names, exts, ids, icon }) { 58 | ids.forEach(content => { 59 | const defaultExtension = Object.values(languages) 60 | .find(({ ids }) => (Array.isArray(ids) ? ids : [ids]).includes(content)) 61 | .defaultExtension.toLowerCase() 62 | if (!exts.includes(defaultExtension)) exts.push(defaultExtension) 63 | }) 64 | const iconFile = icon.replace(/^.*?file_type_(.*?)\..*$/, '$1') 65 | const cols = [name, names.join(':'), exts.join(':')] 66 | if (!['file'].includes(name) && name !== iconFile) cols.push(iconFile) 67 | return cols 68 | } 69 | 70 | exports.fileName = fileName 71 | exports.link = link 72 | exports.parsePageContent = parsePageContent 73 | exports.prepareCSV = prepareCSV 74 | -------------------------------------------------------------------------------- /scripts/vscode-icons/generate-folder-icon-index.js: -------------------------------------------------------------------------------- 1 | const fileName = 'folder-icons-index' 2 | 3 | const link = 'https://github.com/vscode-icons/vscode-icons/wiki/ListOfFolders' 4 | 5 | function parsePageContent() { 6 | const records = [] 7 | document.body 8 | .querySelector('table') 9 | .querySelectorAll('tbody tr') 10 | .forEach(tr => { 11 | const [name, folderNames, closed, open] = Array.from(tr.querySelectorAll('td')) 12 | const names = folderNames.innerHTML.split(', ') 13 | records.push({ 14 | name: name.innerText, 15 | names, 16 | icon: { 17 | closed: getSrc(closed.querySelector('img')), 18 | open: getSrc(open.querySelector('img')), 19 | }, 20 | }) 21 | }) 22 | return records 23 | 24 | function getSrc(img) { 25 | return img && img.src 26 | } 27 | } 28 | 29 | function prepareCSV({ name, names, icon }) { 30 | const [iconFileOpen, iconFileClosed] = [ 31 | icon.open.replace(/^.*?folder_type_(.*?)_opened\..*$/, '$1'), 32 | icon.closed.replace(/^.*?folder_type_(.*?)\..*$/, '$1'), 33 | ] 34 | const cols = [name, names.join(':')] 35 | if ( 36 | !['folder', 'root_folder'].includes(name) && 37 | (name !== iconFileOpen || iconFileOpen !== iconFileClosed) 38 | ) 39 | cols.push(iconFileOpen, iconFileClosed) 40 | return cols 41 | } 42 | 43 | exports.fileName = fileName 44 | exports.link = link 45 | exports.parsePageContent = parsePageContent 46 | exports.prepareCSV = prepareCSV 47 | -------------------------------------------------------------------------------- /scripts/vscode-icons/generate-icon-index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { promises: fs, existsSync } = require('fs') 3 | const puppeteer = require('puppeteer') 4 | const generateFileIconIndex = require('./generate-file-icon-index') 5 | const generateFolderIconIndex = require('./generate-folder-icon-index') 6 | const { emitDirPath, checkEmitDir } = require('./check-emit-dir') 7 | 8 | let browser 9 | async function getPage() { 10 | const headless = process.env.HEADLESS !== 'false' 11 | browser = browser || (await puppeteer.launch({ headless })) 12 | return await browser.newPage() 13 | } 14 | 15 | async function generateCSV() { 16 | await checkEmitDir() 17 | 18 | await Promise.all( 19 | [generateFileIconIndex, generateFolderIconIndex].map( 20 | async ({ fileName, link, parsePageContent, prepareCSV }) => { 21 | let records 22 | const emitJSONPath = path.resolve(emitDirPath, fileName + '.json') 23 | if (!existsSync(emitJSONPath)) { 24 | const page = await getPage() 25 | await page.goto(link) 26 | records = await page.evaluate(parsePageContent) 27 | await page.close() 28 | await fs.writeFile(emitJSONPath, JSON.stringify(records)) 29 | } else { 30 | records = require(emitJSONPath) 31 | } 32 | 33 | const rowSeparator = '\n' 34 | const columnSeparator = ',' 35 | const csv = records.map(prepareCSV) 36 | 37 | const emitPath = path.resolve(__dirname, '..', 'src/assets/icons') 38 | await fs.writeFile( 39 | path.resolve(emitPath, fileName + '.csv'), 40 | csv.map(cols => cols.join(columnSeparator)).join(rowSeparator), 41 | ) 42 | }, 43 | ), 44 | ) 45 | 46 | if (browser) await browser.close() 47 | } 48 | 49 | generateCSV() 50 | -------------------------------------------------------------------------------- /scripts/vscode-icons/resolve-languages-map.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs').promises 3 | const typescript = require('typescript') 4 | const { emitDirPath, checkEmitDir } = require('./check-emit-dir') 5 | 6 | const files = [path.resolve(__dirname, '..', 'vscode-icons/src/iconsManifest/languages.ts')] 7 | 8 | const options = { 9 | module: typescript.ModuleKind.CommonJS, 10 | target: typescript.ScriptTarget.ES2015, 11 | strict: true, 12 | suppressOutputPathCheck: false, 13 | } 14 | 15 | async function main() { 16 | await checkEmitDir() 17 | 18 | const compilerHost = typescript.createCompilerHost(options) 19 | compilerHost.writeFile = async (fileName, data, writeByteOrderMark, onError, sourceFiles) => { 20 | if (sourceFiles.some(file => files.includes(file.fileName))) { 21 | await fs.writeFile(path.resolve(emitDirPath, path.basename(fileName)), data) 22 | console.log(`Emitted`, fileName) 23 | } else { 24 | console.log(`Skipped`, fileName) 25 | } 26 | } 27 | 28 | const program = typescript.createProgram(files, options, compilerHost) 29 | program.emit() 30 | } 31 | 32 | main() 33 | -------------------------------------------------------------------------------- /server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"] 6 | } 7 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /server/api/gitee.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { createCodeHandler } from './utils' 3 | 4 | const { GITEE_OAUTH_CLIENT_ID = '', GITEE_OAUTH_CLIENT_SECRET = '' } = process.env 5 | 6 | async function oauth(code: string) { 7 | const params = new URLSearchParams({ 8 | grant_type: 'authorization_code', 9 | code: code, 10 | client_id: GITEE_OAUTH_CLIENT_ID, 11 | client_secret: GITEE_OAUTH_CLIENT_SECRET, 12 | redirect_uri: 'https://gitako.enix.one/redirect/', 13 | }) 14 | 15 | const res = await fetch('https://gitee.com/oauth/token?' + params.toString(), { 16 | headers: { 17 | 'Content-Type': 'application/json', 18 | Accept: 'application/json', 19 | }, 20 | redirect: 'follow', 21 | method: 'post', 22 | }) 23 | return res.json() 24 | } 25 | 26 | export default createCodeHandler(oauth) 27 | -------------------------------------------------------------------------------- /server/api/github.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { createCodeHandler } from './utils' 3 | 4 | const { GITHUB_OAUTH_CLIENT_ID = '', GITHUB_OAUTH_CLIENT_SECRET = '' } = process.env 5 | 6 | async function oauth(code: string) { 7 | const res = await fetch('https://github.com/login/oauth/access_token', { 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | Accept: 'application/json', 11 | }, 12 | redirect: 'follow', 13 | method: 'post', 14 | body: JSON.stringify({ 15 | code, 16 | client_id: GITHUB_OAUTH_CLIENT_ID, 17 | client_secret: GITHUB_OAUTH_CLIENT_SECRET, 18 | }), 19 | }) 20 | 21 | const body = await res.json() 22 | const { access_token: accessToken, scope, error_description: errorDescription } = body 23 | if (errorDescription) { 24 | throw new Error(errorDescription) 25 | } else if (scope !== 'repo' || !accessToken || !(typeof accessToken === 'string')) { 26 | console.log(JSON.stringify(body)) 27 | throw new Error(`Cannot resolve response from GitHub`) 28 | } 29 | return accessToken 30 | } 31 | 32 | export default createCodeHandler(oauth) 33 | -------------------------------------------------------------------------------- /server/api/index.ts: -------------------------------------------------------------------------------- 1 | import { NowRequest, NowResponse } from '@now/node' 2 | 3 | export default function (request: NowRequest, response: NowResponse) { 4 | response.writeHead(302, { 5 | Location: 'https://github.com/EnixCoda/Gitako', 6 | }) 7 | response.end() 8 | } 9 | -------------------------------------------------------------------------------- /server/api/redirect.ts: -------------------------------------------------------------------------------- 1 | import { NowRequest, NowResponse } from '@now/node' 2 | import { sendRejection } from './utils' 3 | 4 | export default async function handleRedirect(request: NowRequest, response: NowResponse) { 5 | const { redirect, ...params } = request.query 6 | if (typeof redirect !== 'string' || !redirect) { 7 | return sendRejection(response) 8 | } 9 | 10 | const url = new URL(redirect) 11 | 12 | for (const key of Object.keys(params)) { 13 | const value = params[key] 14 | if (Array.isArray(value)) { 15 | for (const v of value) url.searchParams.append(key, v) 16 | } else url.searchParams.append(key, value) 17 | } 18 | 19 | response.writeHead(307, { Location: url.href }) 20 | response.end() 21 | } 22 | -------------------------------------------------------------------------------- /server/api/utils.ts: -------------------------------------------------------------------------------- 1 | import { NowRequest, NowResponse } from '@now/node' 2 | 3 | export function createCodeHandler(oauthHandler: (code: string) => string | Promise) { 4 | return async function handleCode(request: NowRequest, response: NowResponse) { 5 | const { code } = request.query 6 | try { 7 | setCORSHeaders(response) 8 | if (!request.method || request.method.toLowerCase() !== 'post') { 9 | return sendRejection(response, 405) 10 | } 11 | if (!code || typeof code !== 'string') { 12 | return sendRejection(response, 403) 13 | } 14 | const accessToken = await oauthHandler(code) 15 | writeJSON(response, { accessToken }) 16 | response.end() 17 | } catch (err) { 18 | return sendRejection(response, 400, err instanceof Error ? err.message : '') 19 | } 20 | } 21 | } 22 | 23 | function setCORSHeaders(response: NowResponse) { 24 | response.setHeader('Access-Control-Allow-Origin', '*') 25 | response.setHeader('Access-Control-Allow-Methods', 'POST') 26 | } 27 | 28 | export function sendRejection(response: NowResponse, status = 400, content?: string) { 29 | response.writeHead(status) 30 | response.end(content) 31 | } 32 | 33 | function writeJSON(response: NowResponse, data: unknown) { 34 | response.setHeader('Content-Type', 'application/json') 35 | response.write(JSON.stringify(data)) 36 | } 37 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitako-server", 3 | "version": "0.1.0", 4 | "main": "api/index.ts", 5 | "author": "EnixCoda", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@now/node": "^1.5.0", 9 | "@types/node-fetch": "^2.5.5", 10 | "node-fetch": "^2.6.7", 11 | "typescript": "^3.8.3" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "routes": [ 4 | { "src": "/redirect/?", "dest": "/api/redirect.ts" }, 5 | { "src": "/oauth/github/?", "dest": "/api/github.ts" }, 6 | { "src": "/oauth/gitee/?", "dest": "/api/gitee.ts" }, 7 | { "src": "/", "dest": "/api/index.ts" } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:react/recommended", 9 | "plugin:react-hooks/recommended", 10 | "prettier" 11 | ], 12 | "settings": { 13 | "react": { 14 | "version": "detect" 15 | } 16 | }, 17 | "rules": { 18 | "react-hooks/rules-of-hooks": "off" // for IIFC 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/assets/icons/Gitako-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/src/assets/icons/Gitako-128.png -------------------------------------------------------------------------------- /src/assets/icons/Gitako-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/src/assets/icons/Gitako-256.png -------------------------------------------------------------------------------- /src/assets/icons/Gitako-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/src/assets/icons/Gitako-64.png -------------------------------------------------------------------------------- /src/assets/icons/Gitako.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EnixCoda/Gitako/7c337105285de2a9e4ff52b42d9ae84a2b960621/src/assets/icons/Gitako.png -------------------------------------------------------------------------------- /src/assets/icons/csv.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.csv' { 2 | const content: string 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/icons/png.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const content: string 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import 'webext-dynamic-content-scripts' 2 | import addPermissionToggle from 'webext-permission-toggle' 3 | 4 | addPermissionToggle({ 5 | title: 'Enable Gitako on this domain', 6 | reloadOnSuccess: 'Refresh to activate Gitako?', 7 | }) 8 | -------------------------------------------------------------------------------- /src/common.d.ts: -------------------------------------------------------------------------------- 1 | // Similar to `global.d.ts` but with import/export 2 | import { Dispatch, SetStateAction } from 'react' 3 | 4 | type ReactIO = { 5 | value: T 6 | onChange: Dispatch> 7 | } 8 | 9 | type PropsWithChildren = React.PropsWithChildren> 10 | -------------------------------------------------------------------------------- /src/components/AccessDeniedDescription.tsx: -------------------------------------------------------------------------------- 1 | import { useConfigs } from 'containers/ConfigsContext' 2 | import { GITHUB_OAUTH } from 'env' 3 | import { platform } from 'platforms' 4 | import { GitHub } from 'platforms/GitHub' 5 | import React from 'react' 6 | 7 | export function AccessDeniedDescription() { 8 | const { accessToken } = useConfigs().value 9 | const hasToken = Boolean(accessToken) 10 | 11 | return ( 12 |
13 |

Access Denied

14 | {hasToken ? ( 15 | <> 16 |

17 | Current access token is either invalid or not granted with permissions to access this 18 | project. 19 |

20 | {platform === GitHub && ( 21 |

22 | You can grant or request access{' '} 23 | 26 | here 27 | {' '} 28 | if you setup Gitako with OAuth. Or try clear and set token again. 29 |

30 | )} 31 | 32 | ) : ( 33 |

34 | Gitako needs access token to read this project. Please setup access token in the settings 35 | panel below. 36 |

37 | )} 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Clippy.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import { cx } from 'utils/cx' 3 | import { copyElementContent } from 'utils/DOMHelper' 4 | 5 | type Props = { 6 | codeSnippetElement: Element 7 | } 8 | 9 | const className = 'clippy-wrapper' 10 | export const ClippyClassName = className 11 | 12 | export function Clippy({ codeSnippetElement }: Props) { 13 | const [state, setState] = useState<'normal' | 'success' | 'fail'>('normal') 14 | useEffect(() => { 15 | const timer = window.setTimeout(() => { 16 | setState('normal') 17 | }, 1000) 18 | return () => window.clearTimeout(timer) 19 | }, [state]) 20 | 21 | // Temporary fix: 22 | // React moved root node of event delegation since v17 23 | // onClick on won't work when rendered with `renderReact` 24 | const elementRef = useRef(null) 25 | useEffect(() => { 26 | const element = elementRef.current 27 | if (element) { 28 | const onClippyClick = () => 29 | setState(copyElementContent(codeSnippetElement) ? 'success' : 'fail') 30 | 31 | element.addEventListener('click', onClippyClick) 32 | return () => element.removeEventListener('click', onClippyClick) 33 | } 34 | }, [codeSnippetElement]) 35 | 36 | return ( 37 |
38 | 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/components/FileExplorer/DiffStatGraph.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DiffAddedIcon, 3 | DiffIgnoredIcon, 4 | DiffModifiedIcon, 5 | DiffRemovedIcon, 6 | DiffRenamedIcon, 7 | } from '@primer/octicons-react' 8 | import React from 'react' 9 | import { resolveDiffGraphMeta } from 'utils/general' 10 | import { Icon } from '../Icon' 11 | 12 | const iconMap = { 13 | added: DiffAddedIcon, 14 | ignored: DiffIgnoredIcon, 15 | modified: DiffModifiedIcon, 16 | removed: DiffRemovedIcon, 17 | renamed: DiffRenamedIcon, 18 | } 19 | 20 | export function DiffStatGraph({ 21 | diff: { status, changes, additions, deletions }, 22 | }: { 23 | diff: Required['diff'] 24 | }) { 25 | const { g, r, w } = resolveDiffGraphMeta(additions, deletions, changes) 26 | 27 | const children: React.ReactNode[] = [] 28 | for (let i = 0; i < g; i++) 29 | children.push() 30 | for (let i = 0; i < r; i++) 31 | children.push() 32 | for (let i = 0; i < w; i++) 33 | children.push() 34 | 35 | return ( 36 | 37 | 38 | {children} 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/FileExplorer/DiffStatText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Icon } from '../Icon' 3 | 4 | const iconMap = { 5 | added: 'diffAdded', 6 | ignored: 'diffIgnored', 7 | modified: 'diffModified', 8 | removed: 'diffRemoved', 9 | renamed: 'diffRenamed', 10 | } 11 | 12 | export function DiffStatText({ 13 | diff: { status, additions, deletions }, 14 | }: { 15 | diff: Required['diff'] 16 | }) { 17 | return ( 18 | 19 | 20 | {additions > 0 && {additions}} 21 | {additions > 0 && deletions > 0 && '/'} 22 | {deletions > 0 && {deletions}} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useExpandTo.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' 3 | 4 | export function useExpandTo(visibleNodesGenerator: VisibleNodesGenerator) { 5 | return useCallback( 6 | async (currentPath: string[]) => { 7 | const nodeExpandedTo = await visibleNodesGenerator.expandTo(currentPath.join('/')) 8 | if (nodeExpandedTo) visibleNodesGenerator.focusNode(nodeExpandedTo) 9 | }, 10 | [visibleNodesGenerator], 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useFocusNode.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' 3 | 4 | export function useFocusNode(visibleNodesGenerator: VisibleNodesGenerator) { 5 | return useCallback( 6 | (node: TreeNode | null) => visibleNodesGenerator.focusNode(node), 7 | [visibleNodesGenerator], 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useGetCurrentPath.tsx: -------------------------------------------------------------------------------- 1 | import { platform } from 'platforms' 2 | import { useCallback } from 'react' 3 | 4 | export function useGetCurrentPath({ branchName }: MetaData) { 5 | return useCallback(() => platform.getCurrentPath(branchName), [branchName]) 6 | } 7 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useGoTo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' 3 | import { useExpandTo } from './useExpandTo' 4 | 5 | export function useGoTo( 6 | visibleNodesGenerator: VisibleNodesGenerator, 7 | updateSearchKey: React.Dispatch>, 8 | expandTo: ReturnType, 9 | ) { 10 | return useCallback( 11 | (path: string[]) => { 12 | updateSearchKey('') 13 | visibleNodesGenerator.search(null) 14 | visibleNodesGenerator.onNextUpdate(() => expandTo(path)) 15 | }, 16 | [visibleNodesGenerator, updateSearchKey, expandTo], 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useOnNodeClick.tsx: -------------------------------------------------------------------------------- 1 | import { useConfigs } from 'containers/ConfigsContext' 2 | import React, { useCallback } from 'react' 3 | import { isOpenInNewWindowClick } from 'utils/general' 4 | import { loadWithFastRedirect } from 'utils/hooks/useFastRedirect' 5 | import { AlignMode } from '../useVirtualScroll' 6 | import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods' 7 | 8 | export function useHandleNodeClick( 9 | { toggleExpansion, focusNode }: VisibleNodesGeneratorMethods, 10 | setAlignMode: (mode: AlignMode) => void, 11 | ) { 12 | const { recursiveToggleFolder } = useConfigs().value 13 | return useCallback( 14 | (event: React.MouseEvent, node: TreeNode) => { 15 | setAlignMode('lazy') 16 | switch (node.type) { 17 | case 'tree': { 18 | const recursive = 19 | (recursiveToggleFolder === 'shift' && event.shiftKey) || 20 | (recursiveToggleFolder === 'alt' && event.altKey) 21 | // recursive toggle action may conflict with browser default action 22 | // e.g. shift + click is the default open in new tab action on macOS 23 | // giving recursive toggle action higher priority than default action 24 | if (!recursive && isOpenInNewWindowClick(event)) return 25 | 26 | // check if clicked inside an element which has `data-gitako-bypass-click` set 27 | if (event.target instanceof HTMLElement) { 28 | let e = event.target 29 | while (e.parentElement) { 30 | if (e.dataset.gitakoBypassClick) return 31 | e = e.parentElement 32 | } 33 | } 34 | 35 | event.preventDefault() 36 | toggleExpansion(node, { recursive }) 37 | break 38 | } 39 | case 'blob': { 40 | if (isOpenInNewWindowClick(event)) return 41 | 42 | focusNode(node) 43 | if (node.url) { 44 | const isHashLink = node.url.includes('#') 45 | if (!isHashLink) { 46 | event.preventDefault() 47 | loadWithFastRedirect(node.url, event.currentTarget) 48 | } 49 | } 50 | break 51 | } 52 | case 'commit': { 53 | // pass event, open in new tab thanks to the target="_blank" on the anchor element 54 | } 55 | } 56 | }, 57 | [toggleExpansion, recursiveToggleFolder, focusNode, setAlignMode], 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useOnSearch.tsx: -------------------------------------------------------------------------------- 1 | import { useConfigs } from 'containers/ConfigsContext' 2 | import { useCallback } from 'react' 3 | import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' 4 | import { SearchMode, searchModes } from '../../searchModes' 5 | 6 | export function useOnSearch( 7 | updateSearchKey: (searchKey: string) => void, 8 | visibleNodesGenerator: VisibleNodesGenerator, 9 | ) { 10 | const { restoreExpandedFolders } = useConfigs().value 11 | return useCallback( 12 | (searchKey: string, searchMode: SearchMode) => { 13 | updateSearchKey(searchKey) 14 | visibleNodesGenerator.search( 15 | searchModes[searchMode].getSearchParams(searchKey), 16 | restoreExpandedFolders, 17 | ) 18 | }, 19 | [updateSearchKey, visibleNodesGenerator, restoreExpandedFolders], 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useRenderLabelText.tsx: -------------------------------------------------------------------------------- 1 | import { useConfigs } from 'containers/ConfigsContext' 2 | import { useCallback } from 'react' 3 | import { searchModes } from '../../searchModes' 4 | 5 | export function useRenderLabelText(searchKey: string) { 6 | const { searchMode } = useConfigs().value 7 | return useCallback( 8 | (node: TreeNode) => searchModes[searchMode].renderNodeLabelText(node, searchKey), 9 | [searchKey, searchMode], 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useToggleExpansion.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' 3 | 4 | export function useToggleExpansion(visibleNodesGenerator: VisibleNodesGenerator) { 5 | return useCallback( 6 | async ( 7 | node: TreeNode, 8 | { 9 | recursive = false, 10 | }: { 11 | recursive?: boolean 12 | }, 13 | ) => { 14 | if (node.type === 'tree') { 15 | visibleNodesGenerator.focusNode(node) 16 | await visibleNodesGenerator.toggleExpand(node, recursive) 17 | } 18 | }, 19 | [visibleNodesGenerator], 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx: -------------------------------------------------------------------------------- 1 | import { useConfigs } from 'containers/ConfigsContext' 2 | import { platform } from 'platforms' 3 | import { useCallback, useState } from 'react' 4 | import { useAbortableEffect } from 'utils/hooks/useAbortableEffect' 5 | import { useHandleNetworkError } from 'utils/hooks/useHandleNetworkError' 6 | import { useLoadedContext } from 'utils/hooks/useLoadedContext' 7 | import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' 8 | import { SideBarStateContext } from '../../../containers/SideBarState' 9 | 10 | export function useVisibleNodesGenerator(metaData: MetaData | null) { 11 | const [visibleNodesGenerator, setVisibleNodesGenerator] = useState( 12 | null, 13 | ) 14 | 15 | const config = useConfigs().value 16 | const setStateContext = useLoadedContext(SideBarStateContext).onChange 17 | const handleNetworkError = useHandleNetworkError() 18 | 19 | // Only run when metadata or accessToken changes 20 | const createVNG = useCallback( 21 | async function* createVNG() { 22 | if (!metaData) return 23 | 24 | setStateContext('tree-loading') 25 | const { userName, repoName, branchName } = metaData 26 | try { 27 | const { root: treeRoot, defer = false } = yield await platform.getTreeData( 28 | { 29 | branchName, 30 | userName, 31 | repoName, 32 | }, 33 | '/', 34 | true, 35 | config.accessToken, 36 | ) 37 | setStateContext('tree-rendering') 38 | 39 | setVisibleNodesGenerator( 40 | new VisibleNodesGenerator({ 41 | root: treeRoot, 42 | defer, 43 | compress: config.compressSingletonFolder, 44 | async getTreeData(path) { 45 | const { root } = await platform.getTreeData(metaData, path, false, config.accessToken) 46 | return root 47 | }, 48 | }), 49 | ) 50 | 51 | setStateContext('tree-rendered') 52 | } catch (err) { 53 | if (err instanceof Error) handleNetworkError(err) 54 | else throw err 55 | } 56 | }, 57 | // eslint-disable-next-line react-hooks/exhaustive-deps 58 | [metaData, config.accessToken], 59 | ) 60 | 61 | useAbortableEffect( 62 | useCallback( 63 | () => ({ 64 | getAsyncGenerator: createVNG, 65 | }), 66 | [createVNG], 67 | ), 68 | ) 69 | 70 | return visibleNodesGenerator 71 | } 72 | -------------------------------------------------------------------------------- /src/components/FileExplorer/hooks/useVisibleNodesGeneratorMethods.tsx: -------------------------------------------------------------------------------- 1 | import { platform } from 'platforms' 2 | import { useEffect } from 'react' 3 | import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' 4 | import { useExpandTo } from './useExpandTo' 5 | import { useFocusNode } from './useFocusNode' 6 | import { useGoTo } from './useGoTo' 7 | import { useToggleExpansion } from './useToggleExpansion' 8 | 9 | export function useVisibleNodesGeneratorMethods( 10 | visibleNodesGenerator: VisibleNodesGenerator, 11 | getCurrentPath: () => string[] | null, 12 | updateSearchKey: React.Dispatch>, 13 | ) { 14 | const expandTo = useExpandTo(visibleNodesGenerator) 15 | const goTo = useGoTo(visibleNodesGenerator, updateSearchKey, expandTo) 16 | const toggleExpansion = useToggleExpansion(visibleNodesGenerator) 17 | const focusNode = useFocusNode(visibleNodesGenerator) 18 | 19 | // Only run when visibleNodesGenerator changes 20 | // Confirmed: other items in deps array also only update when that changes 21 | useEffect(() => { 22 | if (platform.shouldExpandAll?.()) { 23 | visibleNodesGenerator.onNextUpdate(visibleNodes => 24 | visibleNodes.nodes.forEach(node => toggleExpansion(node, { recursive: true })), 25 | ) 26 | } else { 27 | const targetPath = getCurrentPath() 28 | if (targetPath) goTo(targetPath) 29 | } 30 | }, [visibleNodesGenerator, getCurrentPath, goTo, toggleExpansion]) 31 | 32 | return { 33 | expandTo, 34 | goTo, 35 | toggleExpansion, 36 | focusNode, 37 | } 38 | } 39 | 40 | export type VisibleNodesGeneratorMethods = ReturnType 41 | -------------------------------------------------------------------------------- /src/components/FileExplorer/useHandleNodeFocus.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { VisibleNodesGeneratorMethods } from './hooks/useVisibleNodesGeneratorMethods' 3 | import { AlignMode } from './useVirtualScroll' 4 | 5 | export function useHandleNodeFocus( 6 | { focusNode }: VisibleNodesGeneratorMethods, 7 | setAlignMode: (mode: AlignMode) => void, 8 | ) { 9 | return useCallback( 10 | (event: React.FocusEvent, node: TreeNode) => { 11 | setAlignMode('lazy') 12 | focusNode(node) 13 | }, 14 | [focusNode, setAlignMode], 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/FileExplorer/useLatestValueRef.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | 3 | function useLatestValueRef(value: T) { 4 | const ref = useRef(value) 5 | useEffect(() => { 6 | ref.current = value 7 | }) 8 | return ref 9 | } 10 | export function useCallbackRef( 11 | callback: (...args: Args) => R, 12 | ): (...args: Args) => R { 13 | const ref = useLatestValueRef(callback) 14 | return useCallback((...args: Args) => ref.current(...args), [ref]) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/FileExplorer/useVisibleNodes.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' 3 | 4 | export function useVisibleNodes(visibleNodesGenerator: VisibleNodesGenerator | null) { 5 | const [visibleNodes, setVisibleNodes] = useState( 6 | visibleNodesGenerator?.visibleNodes || null, 7 | ) 8 | useEffect(() => { 9 | const $visibleNodes = visibleNodesGenerator?.visibleNodes || null 10 | if (visibleNodes !== $visibleNodes) setVisibleNodes($visibleNodes) 11 | 12 | return visibleNodesGenerator?.onUpdate(setVisibleNodes) 13 | }, [visibleNodesGenerator]) // eslint-disable-line react-hooks/exhaustive-deps 14 | return visibleNodes 15 | } 16 | -------------------------------------------------------------------------------- /src/components/FocusTarget.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react' 2 | import { SidebarContext } from './SidebarContext' 3 | 4 | export type FocusTarget = 'files' | 'search' | null 5 | 6 | export function useFocusOnPendingTarget(target: FocusTarget, method: () => void) { 7 | const { pendingFocusTarget } = useContext(SidebarContext) 8 | useEffect(() => { 9 | if (pendingFocusTarget.value === target) { 10 | method() 11 | pendingFocusTarget.onChange(null) 12 | } 13 | }, [target, method, pendingFocusTarget]) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { GearIcon, SyncIcon } from '@primer/octicons-react' 2 | import { Link } from '@primer/react' 3 | import { ReloadContext } from 'containers/ReloadContext' 4 | import { VERSION } from 'env' 5 | import React, { useContext } from 'react' 6 | import { RoundIconButton } from './RoundIconButton' 7 | import { wikiLinks } from './settings/SettingsBar' 8 | 9 | type Props = { 10 | toggleShowSettings: () => void 11 | } 12 | 13 | export function Footer(props: Props) { 14 | const { toggleShowSettings } = props 15 | const reload = useContext(ReloadContext) 16 | return ( 17 |
18 |
19 | 26 | {VERSION} 27 | 28 | 34 | 👋 35 | 36 |
37 |
38 | reload()} 43 | /> 44 | 50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/Gitako.tsx: -------------------------------------------------------------------------------- 1 | import { SideBar } from 'components/SideBar' 2 | import { ConfigsContextWrapper } from 'containers/ConfigsContext' 3 | import { InspectorContextWrapper } from 'containers/Inspector' 4 | import { ReloadContextWrapper } from 'containers/ReloadContext' 5 | import React, { useMemo } from 'react' 6 | import { StyleSheetManager } from 'styled-components' 7 | import { insertMountPoint } from 'utils/DOMHelper' 8 | import { ErrorBoundary } from '../containers/ErrorBoundary' 9 | import { StateBarErrorContextWrapper } from '../containers/ErrorContext' 10 | import { OAuthWrapper } from '../containers/OAuthWrapper' 11 | import { RepoContextWrapper } from '../containers/RepoContext' 12 | import { StateBarStateContextWrapper } from '../containers/SideBarState' 13 | 14 | export function Gitako() { 15 | const mountPoint = useMemo(() => insertMountPoint(), []) 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Highlight.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import React, { ComponentProps } from 'react' 3 | import { Highlight } from './Highlight' 4 | 5 | function test( 6 | title: string, 7 | text: string, 8 | match: ComponentProps['match'], 9 | expected = text, 10 | ) { 11 | it(title, () => { 12 | expect(render().container.textContent).toBe(expected) 13 | }) 14 | } 15 | 16 | test('abc', 'abc', undefined) 17 | test('abc1', 'abc', /./) 18 | test('abc2', 'abc', /../) 19 | test('abc3', 'abc', /.../) 20 | test('abc4', 'abc', /..../) 21 | test('abcd', 'abcd', /^((?!ab)cd)*$/, 'abcd') 22 | -------------------------------------------------------------------------------- /src/components/Highlight.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import { getIsSupportedRegex } from './searchModes/regexMode' 3 | 4 | export const Highlight = function Highlight({ text, match }: { text: string; match?: RegExp }) { 5 | const $match = useMemo( 6 | () => 7 | match instanceof RegExp 8 | ? match.flags.includes('g') 9 | ? match 10 | : new RegExp(match.source, 'g' + match.flags) 11 | : null, 12 | [match], 13 | ) 14 | 15 | const chunks = useMemo(() => getChunks(text, $match), [text, $match]) 16 | 17 | return <>{chunks.map(([type, text], key) => React.createElement(type, { key }, text))} 18 | } 19 | 20 | type ElementMeta = [tag: string, content: string] 21 | function getChunks(text: string, match: RegExp | null): ElementMeta[] { 22 | const contents: ElementMeta[] = [] 23 | 24 | const isSupportedMode = match && getIsSupportedRegex(match.source) 25 | if (!isSupportedMode) { 26 | contents.push(['span', text]) 27 | return contents 28 | } 29 | 30 | const matchedPieces = Array.from(text.matchAll(match)).map( 31 | ([text, highlightText = text]) => highlightText, 32 | ) 33 | const preservedPieces = text.split(match) 34 | 35 | const push = (type: string, text: string) => { 36 | const last = contents[contents.length - 1] 37 | if (last && last[0] === type) last[1] += text 38 | else contents.push([type, text]) 39 | } 40 | 41 | const max = Math.max(matchedPieces.length, preservedPieces.length) 42 | for (let i = 0; i < max; ++i) { 43 | preservedPieces[i] && push('span', preservedPieces[i]) 44 | matchedPieces[i] && push('mark', matchedPieces[i]) 45 | } 46 | return contents 47 | } 48 | -------------------------------------------------------------------------------- /src/components/HighlightOnIndexes.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import React from 'react' 3 | import { is } from 'utils/is' 4 | import { HighlightOnIndexes } from './HighlightOnIndexes' 5 | 6 | function test(title: string, text: string, indexes?: number[]) { 7 | it(title, () => { 8 | expect(render().container.textContent).toBe( 9 | text, 10 | ) 11 | }) 12 | } 13 | 14 | const text = 'abcdef' 15 | for (let i = 0; i < Math.pow(2, text.length); i++) { 16 | const bitwise = i.toString(2) 17 | const indexes = bitwise 18 | .split('') 19 | .reverse() 20 | .map((bit, index) => (bit === '1' ? index : false)) 21 | .filter(is.not.false) 22 | test( 23 | `renders properly when highlight ${bitwise.padStart(text.length, '0')}, indexes [${indexes}]`, 24 | text, 25 | indexes, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/HighlightOnIndexes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function HighlightOnIndexes({ text, indexes = [] }: { text: string; indexes?: number[] }) { 4 | return ( 5 | <> 6 | {[-1] 7 | .concat(indexes) 8 | .map((index, i, arr) => [ 9 | index === -1 ? '' : text.slice(index, index + 1), 10 | text.slice(index + 1, arr[i + 1]), 11 | ]) 12 | .reduce((arr, pair) => { 13 | const last = arr[arr.length - 1] 14 | if (last && !last[1]) { 15 | last[0] += pair[0] 16 | last[1] += pair[1] 17 | } else { 18 | arr.push(pair) 19 | } 20 | return arr 21 | }, [] as string[][]) 22 | .map(([chunk, nextChunk], i) => [ 23 | chunk && {chunk}, 24 | nextChunk && {nextChunk}, 25 | ])} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from '@primer/octicons-react' 2 | import { Box, merge, SxProp, useTheme } from '@primer/react' 3 | import { getBaseStyles, getSizeStyles, getVariantStyles } from '@primer/react/lib-esm/Button/styles' 4 | import { 5 | IconButtonProps as PrimerIconButtonProps, 6 | StyledButton, 7 | } from '@primer/react/lib-esm/Button/types' 8 | import React, { forwardRef } from 'react' 9 | import { is } from 'utils/is' 10 | 11 | export type IconButtonProps = PrimerIconButtonProps & { 12 | iconSize?: IconProps['size'] 13 | iconColor?: string 14 | } 15 | 16 | // Modified version of @primer/react/lib-esm/Button/Button.tsx 17 | // Added better support of colors & size 18 | 19 | export const IconButton = forwardRef(function IconButton( 20 | props: IconButtonProps, 21 | ref: React.ForwardedRef, 22 | ) { 23 | const { 24 | variant = 'default', 25 | size = 'medium', 26 | iconSize, // grow the icon to the same size as the button 27 | iconColor, // extra control of icon color 28 | sx: sxProp = {}, 29 | icon: Icon, 30 | ...rest 31 | } = props 32 | const { theme } = useTheme() 33 | const sxStyles = merge.all( 34 | [ 35 | getBaseStyles(theme), 36 | getSizeStyles(size, variant, true), 37 | getVariantStyles(variant, theme), 38 | // Unsatisfied with preset color of the `invisible` variant 39 | { 40 | color: iconColor || (variant === 'invisible' ? 'fg.subtle' : undefined), 41 | }, 42 | sxProp as SxProp, 43 | ].filter(is.not.undefined), 44 | ) 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | ) 52 | }) 53 | -------------------------------------------------------------------------------- /src/components/Inputs/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { CheckboxProps, FormControl, Checkbox as PrimerCheckbox } from '@primer/react' 2 | import React from 'react' 3 | 4 | export function Checkbox({ 5 | label, 6 | value, 7 | onChange, 8 | checked = value, 9 | ...rest 10 | }: Override>) { 11 | return ( 12 | 13 | onChange(e.target.checked)} 19 | {...rest} 20 | /> 21 | {label} 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Inputs/SelectInput.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, Select, SelectProps } from '@primer/react' 2 | import React from 'react' 3 | 4 | export type Option = { 5 | key: string 6 | label: string 7 | value: T 8 | } 9 | 10 | export type SelectInputProps = Override< 11 | SelectProps, 12 | IO & { 13 | label: React.ReactNode 14 | options: Option[] 15 | } 16 | > 17 | 18 | export function SelectInput({ 19 | value, 20 | onChange, 21 | label, 22 | options, 23 | ...selectProps 24 | }: SelectInputProps) { 25 | return ( 26 | span': { 30 | // original boxShadow does not look right 31 | borderWidth: '2px', 32 | boxShadow: 'none', 33 | '> select': { 34 | paddingLeft: '11px', 35 | paddingRight: '11px', 36 | }, 37 | }, 38 | }, 39 | mb: 1, 40 | }} 41 | disabled={selectProps.disabled} 42 | > 43 | {label} 44 | 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/components/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { HourglassIcon } from '@primer/octicons-react' 2 | import React from 'react' 3 | 4 | type Props = { 5 | text: React.ReactNode 6 | } 7 | export function LoadingIndicator({ text }: Props) { 8 | return ( 9 |
10 |
11 | 12 | {text} 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/MetaBar.tsx: -------------------------------------------------------------------------------- 1 | import { GitBranchIcon } from '@primer/octicons-react' 2 | import { Box, BranchName, Breadcrumbs, Text } from '@primer/react' 3 | import { RepoContext } from 'containers/RepoContext' 4 | import { platform } from 'platforms' 5 | import React, { useContext } from 'react' 6 | import { createAnchorClickHandler } from 'utils/createAnchorClickHandler' 7 | 8 | export function MetaBar() { 9 | const metaData = useContext(RepoContext) 10 | if (!metaData) return null 11 | 12 | const { userName, repoName, branchName } = metaData 13 | const { repoUrl, userUrl, branchUrl } = platform.resolveUrlFromMetaData(metaData) 14 | return ( 15 | <> 16 | 17 | 18 | {userName} 19 | 20 | 26 | {repoName} 27 | 28 | 29 | 30 |
31 | 32 |
33 | 44 | {branchName || '...'} 45 | 46 |
47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/components/Portal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | 4 | type Props = { 5 | into: Element | null 6 | } 7 | 8 | export function Portal(props: React.PropsWithChildren) { 9 | const { into, children } = props 10 | if (!(into instanceof Element)) return null 11 | return ReactDOM.createPortal(children, into) 12 | } 13 | -------------------------------------------------------------------------------- /src/components/ResizeHandler.tsx: -------------------------------------------------------------------------------- 1 | import { GrabberIcon } from '@primer/octicons-react' 2 | import { Icon } from 'components/Icon' 3 | import React from 'react' 4 | import { ResizeHandlerOptions, useResizeHandler } from '../utils/hooks/useResizeHandler' 5 | import { Size2D } from './Size' 6 | 7 | type Props = { 8 | size: Size2D 9 | onResize(size: Size2D): void 10 | onResetSize?(): void 11 | options?: ResizeHandlerOptions 12 | style?: React.CSSProperties 13 | } 14 | 15 | export function ResizeHandler({ onResize, onResetSize, options, size, style }: Props) { 16 | const { onPointerDown } = useResizeHandler(size, onResize, options) 17 | 18 | return ( 19 |
25 | 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/RoundIconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { ForwardedRef, forwardRef } from 'react' 2 | import { IconButton, IconButtonProps } from './IconButton' 3 | 4 | export const RoundIconButton = forwardRef(function RoundIconButton( 5 | props: IconButtonProps, 6 | ref: ForwardedRef, 7 | ) { 8 | return ( 9 | 19 | ) 20 | }) 21 | -------------------------------------------------------------------------------- /src/components/SidebarContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { noop } from 'utils/general' 3 | import { FocusTarget } from './FocusTarget' 4 | 5 | // Use this to pass state across components under Sidebar 6 | export const SidebarContext = React.createContext<{ 7 | pendingFocusTarget: IO 8 | }>({ 9 | pendingFocusTarget: { onChange: noop, value: null }, 10 | }) 11 | -------------------------------------------------------------------------------- /src/components/Size.tsx: -------------------------------------------------------------------------------- 1 | export type Size = number 2 | export type Size2D = [Size, Size] 3 | -------------------------------------------------------------------------------- /src/components/searchModes/fuzzyMode.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ 2 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 3 | import { fuzzyMode } from './fuzzyMode' 4 | 5 | type TreeNodeSource = { 6 | [key: string]: true | TreeNodeSource 7 | } 8 | 9 | function createTreeNode(source: TreeNodeSource, name = '', paths: string[] = []): TreeNode { 10 | const subPaths = paths.concat(name) 11 | return { 12 | name, 13 | path: subPaths.join('/'), 14 | type: 'tree', 15 | contents: Object.entries(source).map(([key, value]) => { 16 | return value === true 17 | ? { 18 | name: key, 19 | path: subPaths.concat(key).join('/'), 20 | type: 'blob', 21 | } 22 | : createTreeNode(value, key, subPaths) 23 | }), 24 | } 25 | } 26 | 27 | const node = createTreeNode({ 28 | a: { 29 | b: { 30 | ['c.json']: true, 31 | }, 32 | }, 33 | }) 34 | 35 | it(`finds the items that matches all search key chars`, () => { 36 | // a/b/c.json 37 | // "b" => 38 | // ✅a/b 39 | // ❌a 40 | expect(fuzzyMode.getSearchParams('b')?.matchNode(node.contents?.[0].contents?.[0]!)).toBe(true) 41 | expect(fuzzyMode.getSearchParams('b')?.matchNode(node.contents?.[0]!)).toBe(false) 42 | }) 43 | 44 | it(`excludes files under the dir that matches last search key char`, () => { 45 | // a/b/c.json 46 | // "b" => 47 | // ❌a/b/c.json 48 | expect( 49 | fuzzyMode.getSearchParams('b')?.matchNode(node.contents?.[0].contents?.[0].contents?.[0]!), 50 | ).toBe(false) 51 | expect( 52 | fuzzyMode.getSearchParams('tooltip')?.matchNode({ 53 | name: '', 54 | type: 'tree', 55 | path: '/components/tooltip', 56 | }), 57 | ).toBe(true) 58 | expect( 59 | fuzzyMode.getSearchParams('tooltip')?.matchNode({ 60 | name: '', 61 | type: 'blob', 62 | path: '/components/tooltip/x', 63 | }), 64 | ).toBe(false) 65 | expect( 66 | fuzzyMode.getSearchParams('tooltip/')?.matchNode({ 67 | name: '', 68 | type: 'blob', 69 | path: '/components/tooltip/x', 70 | }), 71 | ).toBe(true) 72 | }) 73 | -------------------------------------------------------------------------------- /src/components/searchModes/fuzzyMode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cx } from 'utils/cx' 3 | import { hasUpperCase } from 'utils/general' 4 | import { ModeShape } from '.' 5 | import { HighlightOnIndexes } from '../HighlightOnIndexes' 6 | 7 | export const fuzzyMode: ModeShape = { 8 | getSearchParams(searchKey) { 9 | if (!searchKey) return null 10 | 11 | const matchNode = (node: TreeNode) => { 12 | const path = hasUpperCase(searchKey) ? node.path : node.path.toLowerCase() 13 | const { match, lastIndex } = fuzzyMatch(searchKey, path) 14 | return match && (searchKey[searchKey.length - 1] === '/' || lastIndex > path.lastIndexOf('/')) 15 | } 16 | return { 17 | matchNode, 18 | } 19 | }, 20 | renderNodeLabelText(node, searchKey) { 21 | const { name } = node 22 | const path = hasUpperCase(searchKey) ? node.path : node.path.toLowerCase() 23 | 24 | const indexes = fuzzyMatchIndexes(searchKey, path, path.length - name.length) 25 | const chunks = name.split('/') 26 | let progress = 0 27 | return chunks.map((chunk, index, chunks) => { 28 | const chunkIndexes = indexes.filter(i => i >= progress && i < chunk.length + progress) 29 | 30 | const highlightIndexes = chunkIndexes.length ? chunkIndexes.map(i => i - progress) : undefined 31 | 32 | progress += chunk.length + 1 // not neat side effect in map function 33 | return ( 34 | 35 | 39 | 40 | ) 41 | }) 42 | }, 43 | } 44 | 45 | function fuzzyMatch(input: string, sample: string) { 46 | let i = 0, 47 | j = 0 48 | while (i < input.length && j < sample.length) { 49 | if (input[i] === sample[j++]) i++ 50 | } 51 | return { 52 | lastIndex: j - 1, 53 | match: i === input.length, 54 | } 55 | } 56 | 57 | function fuzzyMatchIndexes(input: string, sample: string, shift = 0) { 58 | const indexes: number[] = [] 59 | let i = 0, 60 | j = 0 61 | while (i < input.length && j < sample.length) { 62 | if (input[i] === sample[j]) { 63 | if (j >= shift) indexes.push(j - shift) 64 | i++ 65 | } 66 | j++ 67 | } 68 | return indexes 69 | } 70 | -------------------------------------------------------------------------------- /src/components/searchModes/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { SearchParams } from 'utils/VisibleNodesGenerator' 3 | import { fuzzyMode } from './fuzzyMode' 4 | import { regexMode } from './regexMode' 5 | 6 | export type SearchMode = 'regex' | 'fuzzy' 7 | 8 | export type ModeShape = { 9 | getSearchParams(searchKey: string): Pick | null 10 | renderNodeLabelText(node: TreeNode, searchKey: string): ReactNode 11 | } 12 | 13 | export const searchModes: Record = { 14 | regex: regexMode, 15 | fuzzy: fuzzyMode, 16 | } 17 | -------------------------------------------------------------------------------- /src/components/searchModes/regexMode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cx } from 'utils/cx' 3 | import { searchKeyToRegexp } from 'utils/general' 4 | import { ModeShape } from '.' 5 | import { Highlight } from '../Highlight' 6 | 7 | export const getIsSupportedRegex = (source: string) => !source.match(/\?:|\?=|\?!|\?<=|\? { 15 | regexp.lastIndex = 0 16 | return regexp.test(node.name) 17 | }, 18 | } 19 | } 20 | 21 | return null 22 | }, 23 | renderNodeLabelText(node, searchKey) { 24 | const regex = searchKeyToRegexp(searchKey) || undefined 25 | const { name } = node 26 | return name.includes('/') ? ( 27 | name.split('/').map((chunk, index, chunks) => ( 28 | 29 | 30 | {index + 1 !== chunks.length && '/'} 31 | 32 | )) 33 | ) : ( 34 | 35 | ) 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /src/components/settings/KeyboardShortcutSetting.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, FormControl, TextInput } from '@primer/react' 2 | import React, { useMemo } from 'react' 3 | import { useUpdateEffect } from 'react-use' 4 | import { cancelEvent } from 'utils/DOMHelper' 5 | import { friendlyFormatShortcut, noop } from 'utils/general' 6 | import { useStateIO } from 'utils/hooks/useStateIO' 7 | import * as keyHelper from 'utils/keyHelper' 8 | 9 | type Props = IO & { 10 | label: React.ReactNode 11 | } 12 | 13 | export function KeyboardShortcutSetting({ label, value, onChange }: Props) { 14 | const $focused = useStateIO(false) 15 | const $shortcut = useStateIO(value) 16 | useUpdateEffect(() => $shortcut.onChange(value), [value]) 17 | 18 | const id = useMemo(() => Math.random() + '', []) 19 | 20 | return ( 21 | 22 | {label} 23 | 24 | $focused.onChange(true)} 28 | onBlur={() => $focused.onChange(false)} 29 | placeholder={$focused.value ? 'Press key combination' : 'Click here to set'} 30 | value={friendlyFormatShortcut($shortcut.value)} 31 | onChange={noop} 32 | onKeyDown={e => { 33 | switch (e.key) { 34 | case 'Esc': 35 | case 'Tab': 36 | return 37 | case 'Delete': 38 | case 'Backspace': 39 | // Clear shortcut with backspace 40 | $shortcut.onChange(undefined) 41 | return 42 | default: 43 | cancelEvent(e) 44 | } 45 | $shortcut.onChange(keyHelper.parseEvent(e)) 46 | }} 47 | /> 48 | 55 | 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/components/settings/SettingsSection.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@primer/react' 2 | import React from 'react' 3 | 4 | type Props = { 5 | title?: React.ReactNode 6 | } 7 | 8 | export function SettingsSection({ title, children }: React.PropsWithChildren) { 9 | return ( 10 | 11 | {title &&

{title}

} 12 | {children} 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/settings/SidebarSettings.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleConfigFieldCheckbox } from 'components/settings/SimpleConfigField/Checkbox' 2 | import { useConfigs } from 'containers/ConfigsContext' 3 | import React from 'react' 4 | import { subIO } from 'utils/general' 5 | import { KeyboardShortcutSetting } from './KeyboardShortcutSetting' 6 | import { SettingsSection } from './SettingsSection' 7 | import { SimpleConfigFieldSelect } from './SimpleConfigField/SelectInput' 8 | 9 | export function SidebarSettings() { 10 | const { sidebarToggleMode } = useConfigs().value 11 | 12 | return ( 13 | 14 | 18 | 22 | 41 | (sidebarToggleMode === 'float' ? false : enabled === null), 51 | onChange: checked => (checked ? null : true), 52 | }, 53 | }} 54 | /> 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/components/settings/SimpleConfigField/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ConfigKeys } from 'utils/config/helper' 3 | import { SimpleConfigFieldProps, useSimpleConfigFieldIO } from '.' 4 | import { Checkbox } from '../../Inputs/Checkbox' 5 | import { FieldLabel } from './FieldLabel' 6 | 7 | export function SimpleConfigFieldCheckbox({ 8 | field, 9 | }: SimpleConfigFieldProps) { 10 | const { value, onChange } = useSimpleConfigFieldIO(field) as IO 11 | 12 | return ( 13 | } 15 | disabled={field.disabled} 16 | value={value} 17 | onChange={onChange} 18 | /> 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/settings/SimpleConfigField/FieldLabel.tsx: -------------------------------------------------------------------------------- 1 | import { InfoIcon, LinkExternalIcon } from '@primer/octicons-react' 2 | import { Box } from '@primer/react' 3 | import React from 'react' 4 | import { ConfigKeys } from 'utils/config/helper' 5 | import { SimpleConfigField } from '.' 6 | 7 | export function FieldLabel({ 8 | label, 9 | wikiLink, 10 | tooltip, 11 | }: SimpleConfigField) { 12 | return ( 13 | <> 14 | {label} 15 | {(wikiLink || tooltip) && ' '} 16 | {wikiLink ? ( 17 |
24 | 25 | 26 | ) : ( 27 | tooltip && ( 28 | 38 | 39 | 40 | ) 41 | )} 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/settings/SimpleConfigField/SelectInput.tsx: -------------------------------------------------------------------------------- 1 | import { SelectInput, SelectInputProps } from 'components/Inputs/SelectInput' 2 | import React from 'react' 3 | import { Config, ConfigKeys } from 'utils/config/helper' 4 | import { SimpleConfigFieldProps, useSimpleConfigFieldIO } from '.' 5 | import { FieldLabel } from './FieldLabel' 6 | 7 | export function SimpleConfigFieldSelect({ 8 | field, 9 | options, 10 | }: SimpleConfigFieldProps & { 11 | options: SelectInputProps['options'] 12 | }) { 13 | const { value, onChange } = useSimpleConfigFieldIO(field) 14 | 15 | return ( 16 | } 18 | disabled={field.disabled} 19 | value={value} 20 | onChange={onChange} 21 | options={options} 22 | /> 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/settings/SimpleConfigField/index.tsx: -------------------------------------------------------------------------------- 1 | import { useConfigs } from 'containers/ConfigsContext' 2 | import { useCallback, useMemo } from 'react' 3 | import { Config, ConfigKeys } from 'utils/config/helper' 4 | 5 | export type SimpleConfigField = { 6 | label: string 7 | wikiLink?: string 8 | tooltip?: string 9 | key: Key 10 | disabled?: boolean 11 | overwrite?: { 12 | value: (value: Config[Key]) => Config[Key] 13 | onChange: (newValue: Config[Key]) => Config[Key] 14 | } 15 | } 16 | 17 | export type SimpleConfigFieldProps = { 18 | field: SimpleConfigField 19 | } 20 | 21 | export function useSimpleConfigFieldIO( 22 | field: SimpleConfigField, 23 | ): IO { 24 | const { overwrite } = field 25 | const configContext = useConfigs() 26 | const value = configContext.value[field.key] 27 | 28 | return { 29 | value: useMemo(() => (overwrite ? overwrite.value(value) : value), [overwrite, value]), 30 | onChange: useCallback( 31 | (newValue: Config[Key]) => { 32 | configContext.onChange({ [field.key]: overwrite ? overwrite.onChange(newValue) : newValue }) 33 | }, 34 | [field.key, overwrite, configContext], 35 | ), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/containers/ConfigsContext.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'common' 2 | import React, { useCallback, useContext, useEffect, useState } from 'react' 3 | import { Config, configHelper } from 'utils/config/helper' 4 | 5 | type ContextShape = IO> 6 | export type ConfigsContextShape = ContextShape 7 | 8 | export const ConfigsContext = React.createContext(null) 9 | 10 | export function ConfigsContextWrapper(props: PropsWithChildren) { 11 | const [configs, setConfigs] = useState(null) 12 | useEffect(() => { 13 | configHelper.get().then(setConfigs) 14 | }, []) 15 | const onChange = useCallback( 16 | (updatedConfigs: Partial) => { 17 | const mergedConfigs = { ...configs, ...updatedConfigs } as Config 18 | configHelper.set(mergedConfigs) 19 | setConfigs(mergedConfigs) 20 | }, 21 | [configs, setConfigs], 22 | ) 23 | if (configs === null) return null 24 | return ( 25 | 26 | {props.children} 27 | 28 | ) 29 | } 30 | 31 | export const useConfigs = createUseNonNullContext(ConfigsContext) 32 | 33 | function createUseNonNullContext(theContext: React.Context): () => T { 34 | return () => { 35 | const context = useContext(theContext) 36 | if (context === null) throw new Error(`Empty context`) 37 | return context 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/containers/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { raiseError } from 'analytics' 2 | import { PropsWithChildren } from 'common' 3 | import React from 'react' 4 | 5 | export class ErrorBoundary extends React.PureComponent { 6 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 7 | raiseError(error, errorInfo) 8 | } 9 | 10 | render() { 11 | return this.props.children 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/containers/ErrorContext.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'common' 2 | import { useInspector } from 'containers/Inspector' 3 | import React from 'react' 4 | import { useStateIO } from 'utils/hooks/useStateIO' 5 | 6 | export type SideBarErrorContextShape = IO 7 | 8 | export const SideBarErrorContext = React.createContext(null) 9 | 10 | export function StateBarErrorContextWrapper({ children }: PropsWithChildren) { 11 | const $error = useStateIO(null) 12 | useInspector('SideBarErrorContext', $error.value) 13 | 14 | return {children} 15 | } 16 | -------------------------------------------------------------------------------- /src/containers/Inspector.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, ReactIO } from 'common' 2 | import { IN_PRODUCTION_MODE } from 'env' 3 | import React, { useContext, useEffect } from 'react' 4 | import { Config } from 'utils/config/helper' 5 | import { noop } from 'utils/general' 6 | import { useStateIO } from 'utils/hooks/useStateIO' 7 | import { useConfigs } from './ConfigsContext' 8 | 9 | export type InspectorContextShape = ReactIO 10 | 11 | export const InspectorContext = React.createContext(null) 12 | 13 | export const InspectorContextWrapper = IN_PRODUCTION_MODE 14 | ? React.Fragment 15 | : function InspectorContextWrapper({ children }: PropsWithChildren) { 16 | const $ = useStateIO({}) 17 | const configs = useConfigs() 18 | const { __showInspector: show } = configs.value 19 | const setShow = (__showInspector: Config['__showInspector']) => 20 | configs.onChange({ __showInspector }) 21 | 22 | return ( 23 | 24 | {show ? ( 25 |
38 |
39 | 40 |
41 |
47 |                 {JSON.stringify($.value, null, 2)}
48 |               
49 |
50 | ) : ( 51 |
58 | 59 |
60 | )} 61 | {children} 62 |
63 | ) 64 | } 65 | 66 | export const useInspector = IN_PRODUCTION_MODE 67 | ? noop 68 | : function useInspector(key: string, value: JSONValue) { 69 | const $ = useContext(InspectorContext) 70 | useEffect(() => { 71 | $?.onChange(prev => ({ ...prev, [key]: value })) 72 | }, [key, value]) // eslint-disable-line react-hooks/exhaustive-deps 73 | } 74 | -------------------------------------------------------------------------------- /src/containers/OAuthWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'common' 2 | import { useConfigs } from 'containers/ConfigsContext' 3 | import { platform } from 'platforms' 4 | import React, { useEffect, useRef } from 'react' 5 | import { parseURLSearch, run } from 'utils/general' 6 | import { useLoadedContext } from 'utils/hooks/useLoadedContext' 7 | import { useStateIO } from 'utils/hooks/useStateIO' 8 | import { sanitizedLocation } from 'utils/URLHelper' 9 | import { SideBarStateContext } from './SideBarState' 10 | 11 | /** 12 | * Setup access token before sending other requests 13 | */ 14 | export function OAuthWrapper({ children }: PropsWithChildren) { 15 | const running = useGetAccessToken() 16 | const $state = useLoadedContext(SideBarStateContext) 17 | 18 | const needGetAccessTokenRef = useRef(running) 19 | useEffect(() => { 20 | if (needGetAccessTokenRef.current) { 21 | $state.onChange(running ? 'getting-access-token' : 'after-getting-access-token') 22 | } 23 | }, [running]) // eslint-disable-line react-hooks/exhaustive-deps 24 | 25 | // block children rendering on the first render if setting token 26 | if (running && $state.value !== 'getting-access-token') return null 27 | return <>{children} 28 | } 29 | 30 | function useGetAccessToken() { 31 | const $block = useStateIO(() => Boolean(getCodeSearchParam())) 32 | const configContext = useConfigs() 33 | const { accessToken } = configContext.value 34 | useEffect(() => { 35 | run(async function () { 36 | const code = getCodeSearchParam() 37 | if (code && !accessToken) { 38 | const accessToken = await getAccessTokenWithCode(code) 39 | if (accessToken) configContext.onChange({ accessToken }) 40 | } 41 | $block.onChange(false) 42 | }) 43 | }, []) // eslint-disable-line react-hooks/exhaustive-deps 44 | 45 | return $block.value 46 | } 47 | 48 | function getCodeSearchParam() { 49 | return parseURLSearch().get('code') 50 | } 51 | 52 | async function getAccessTokenWithCode(code: string) { 53 | const accessToken = await platform.setOAuth(code) 54 | if (!accessToken) alert(`Gitako: The OAuth token may have expired, please try again.`) 55 | const search = parseURLSearch() 56 | search.delete('code') 57 | window.history.replaceState( 58 | {}, 59 | 'removed search param', 60 | sanitizedLocation.pathname.replace(window.location.search, '?' + search.toString()), 61 | ) 62 | return accessToken 63 | } 64 | -------------------------------------------------------------------------------- /src/containers/PortalContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export type PortalContextShape = string | null 4 | 5 | export const PortalContext = React.createContext(null) 6 | -------------------------------------------------------------------------------- /src/containers/ReloadContext.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'common' 2 | import React, { useCallback, useState } from 'react' 3 | import { noop } from 'utils/general' 4 | 5 | export type ReloadContextShape = () => void 6 | 7 | export const ReloadContext = React.createContext(noop) 8 | 9 | export function ReloadContextWrapper({ children }: PropsWithChildren) { 10 | const [key, setKey] = useState(0) 11 | const reload = useCallback(() => setKey(key => key + 1), []) 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/containers/SideBarState.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'common' 2 | import React, { useMemo } from 'react' 3 | import { useLoadedContext } from 'utils/hooks/useLoadedContext' 4 | import { useStateIO } from 'utils/hooks/useStateIO' 5 | import { SideBarErrorContext } from './ErrorContext' 6 | import { useInspector } from './Inspector' 7 | 8 | export type SideBarState = 9 | | 'disabled' 10 | | 'getting-access-token' 11 | | 'after-getting-access-token' // mid-state for a smoother state switch out of 'getting-access-token' 12 | | 'meta-loading' 13 | | 'meta-loaded' 14 | | 'tree-loading' 15 | | 'tree-rendering' 16 | | 'tree-rendered' 17 | | 'idle' 18 | | 'error' // when error occurs, sidebar should never expand 19 | | 'error-due-to-auth' // this is a special error, user can expand sidebar and set token to fix the error 20 | 21 | export type SideBarStateContextShape = IO 22 | 23 | export const SideBarStateContext = React.createContext(null) 24 | 25 | export function StateBarStateContextWrapper({ children }: PropsWithChildren) { 26 | const $state = useStateIO('disabled') 27 | useInspector('SideBarStateContext', $state.value) 28 | const error = useLoadedContext(SideBarErrorContext).value 29 | const $$state: IO = useMemo( 30 | () => 31 | error && $state.value !== 'error' 32 | ? { 33 | ...$state, 34 | value: 'error', 35 | } 36 | : $state, 37 | [$state, error], 38 | ) 39 | 40 | return {children} 41 | } 42 | -------------------------------------------------------------------------------- /src/containers/Theme.tsx: -------------------------------------------------------------------------------- 1 | import primitives from '@primer/primitives' 2 | import { BaseStyles, ThemeProvider } from '@primer/react' 3 | import theme from '@primer/react/lib-esm/theme' 4 | import { PropsWithChildren } from 'common' 5 | import React, { useEffect, useState } from 'react' 6 | 7 | // Temporary color fix for out-of-date embedded @primer/primitives in @primer/react 8 | // The `*_tritanopia` themes are actually not bundled within @primer/react@35.2.2 9 | // TODO: Upgrade @primer/react to support these themes 10 | const fixedTheme = { 11 | ...theme, 12 | colorSchemes: { 13 | ...theme.colorSchemes, 14 | light_tritanopia: theme.colorSchemes.light, 15 | dark_tritanopia: theme.colorSchemes.dark, 16 | }, 17 | } 18 | 19 | const validColorSchemes = Object.keys(fixedTheme.colorSchemes) as EnumString< 20 | keyof (typeof primitives)['colors'] 21 | >[] 22 | 23 | const colorModeMap: Record = { 24 | dark: 'night', 25 | light: 'day', 26 | } 27 | 28 | const getPreferenceFromDOM = () => { 29 | // { 44 | const match = window.matchMedia('(prefers-color-scheme: dark)') 45 | const update = () => setPrefer(getPreferenceFromDOM) 46 | match.addEventListener('change', update) 47 | return () => match.removeEventListener('change', update) 48 | }, []) 49 | return prefer 50 | } 51 | 52 | export function Theme({ children }: PropsWithChildren) { 53 | const themePreference = useThemePreference() 54 | return ( 55 | 56 | {children} 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/content.scss: -------------------------------------------------------------------------------- 1 | @import './styles/index.scss'; 2 | -------------------------------------------------------------------------------- /src/content.tsx: -------------------------------------------------------------------------------- 1 | import { Gitako } from 'components/Gitako' 2 | import { IN_PRODUCTION_MODE } from 'env' 3 | import React, { useCallback } from 'react' 4 | import { createRoot } from 'react-dom/client' 5 | import { insertMountPoint, insertSideBarMountPoint } from 'utils/DOMHelper' 6 | import { useAfterRedirect } from 'utils/hooks/useFastRedirect' 7 | import { waitForNext } from 'utils/waitForNextEvent' 8 | import './content.scss' 9 | 10 | const renderReact = () => { 11 | const mountPoint = insertSideBarMountPoint() 12 | const MountPointWatcher = () => { 13 | useAfterRedirect(useCallback(() => insertMountPoint(() => mountPoint), [])) 14 | return null 15 | } 16 | const root = createRoot(mountPoint) 17 | root.render( 18 | <> 19 | 20 | 21 | , 22 | ) 23 | 24 | return () => { 25 | root.unmount() 26 | } 27 | } 28 | 29 | // injects a copy of stylesheets so that other extensions(e.g. dark reader) could read 30 | // resolves when style is loaded to prevent render without proper styles 31 | const injectStyles = (url: string) => 32 | new Promise<() => void>(resolve => { 33 | const linkElement = document.createElement('link') 34 | linkElement.setAttribute('rel', 'stylesheet') 35 | linkElement.setAttribute('href', url) 36 | const unload = () => { 37 | linkElement.remove() 38 | } 39 | linkElement.onload = () => resolve(unload) 40 | document.head.appendChild(linkElement) 41 | }) 42 | 43 | const GitakoExclusiveEventType = 'GITAKO_EXCLUSIVE_EVENT' 44 | const GitakoMountedEventType = 'GITAKO_MOUNTED_EVENT' 45 | 46 | Promise.resolve() 47 | .then(() => 48 | document.readyState === 'loading' ? waitForNext.documentEvent('DOMContentLoaded') : null, 49 | ) 50 | .then(() => 51 | Promise.all([injectStyles(browser.runtime.getURL('content.css')), renderReact()]).then( 52 | ([unmountStyles, unmountReact]) => 53 | () => 54 | Promise.all([unmountStyles(), unmountReact()]), 55 | ), 56 | ) 57 | .then(unmount => { 58 | document.dispatchEvent(new CustomEvent(GitakoMountedEventType)) 59 | if (IN_PRODUCTION_MODE) { 60 | waitForNext.documentEvent(GitakoExclusiveEventType).then(unmount) 61 | } else { 62 | waitForNext 63 | .documentEvent(GitakoMountedEventType) 64 | .then(() => document.dispatchEvent(new CustomEvent(GitakoExclusiveEventType))) 65 | } 66 | }) 67 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | export const IN_PRODUCTION_MODE = process.env.NODE_ENV === 'production' 2 | 3 | export const GITHUB_OAUTH = { 4 | clientId: process.env.GITHUB_OAUTH_CLIENT_ID || '', 5 | } 6 | 7 | export const GITEE_OAUTH = { 8 | clientId: process.env.GITEE_OAUTH_CLIENT_ID || '', 9 | } 10 | 11 | export const VERSION = process.env.VERSION 12 | 13 | export const SENTRY = { 14 | PUBLIC_KEY: process.env.SENTRY_PUBLIC_KEY, 15 | PROJECT_ID: process.env.SENTRY_PROJECT_ID, 16 | } 17 | -------------------------------------------------------------------------------- /src/firefox-shim.js: -------------------------------------------------------------------------------- 1 | window.requestAnimationFrame = window.requestAnimationFrame.bind(window) 2 | window.setTimeout = window.setTimeout.bind(window) 3 | window.clearTimeout = window.clearTimeout.bind(window) 4 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | type AnyArray = any[] // eslint-disable-line @typescript-eslint/no-explicit-any 2 | 3 | type MetaData = { 4 | userName: string 5 | repoName: string 6 | branchName: string 7 | type?: EnumString<'tree' | 'blob' | 'pull' | 'commit'> 8 | } 9 | 10 | type PartialMetaData = MakeOptional 11 | 12 | type TreeNode = { 13 | name: string 14 | contents?: TreeNode[] 15 | path: string 16 | type: 'tree' | 'blob' | 'commit' 17 | url?: string 18 | permalink?: string 19 | rawLink?: string 20 | sha?: string 21 | accessDenied?: boolean 22 | comments?: { 23 | active: number 24 | resolved: number 25 | } 26 | diff?: { 27 | status: 'modified' | 'added' | 'removed' | 'renamed' 28 | additions: number 29 | deletions: number 30 | changes: number 31 | } 32 | } 33 | 34 | type IO = { 35 | value: T 36 | onChange(value: ChangeT): void 37 | } 38 | 39 | type Override = Omit & Incoming 40 | type MakeOptional = Override< 41 | Original, 42 | Partial> 43 | > 44 | 45 | type VoidFN = (payload: T) => void 46 | 47 | type Async = T | Promise 48 | 49 | // eslint-disable-next-line @typescript-eslint/ban-types 50 | type EnumString = S | (string & {}) 51 | 52 | type JSONPrimitive = string | number | boolean | null | undefined 53 | type JSONObject = { 54 | [key: string]: JSONValue 55 | } 56 | type JSONArray = JSONValue[] 57 | type JSONValue = JSONPrimitive | JSONObject | JSONArray 58 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Gitako - GitHub file tree", 4 | "icons": { 5 | "64": "icons/Gitako-64.png", 6 | "128": "icons/Gitako-128.png", 7 | "256": "icons/Gitako-256.png" 8 | }, 9 | "permissions": ["scripting", "storage", "contextMenus", "activeTab"], 10 | "host_permissions": ["*://*.github.com/*", "*://gitako.enix.one/*", "*://*.sentry.io/*"], 11 | "optional_host_permissions": ["*://*/*"], 12 | "web_accessible_resources": [ 13 | { 14 | "resources": ["icons/vscode/*"], 15 | "matches": ["http://*/*", "https://*/*"] 16 | }, 17 | { 18 | "resources": ["content.css"], 19 | "matches": ["http://*/*", "https://*/*"] 20 | } 21 | ], 22 | "background": { 23 | "service_worker": "background.js" 24 | }, 25 | "action": { 26 | "default_icon": "icons/Gitako.png" 27 | }, 28 | "content_scripts": [ 29 | { 30 | "matches": ["https://github.com/*"], 31 | "js": ["firefox-shim.js", "browser-polyfill.js", "content.js"] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/platforms/GitHub/CopyFileButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import { cx } from 'utils/cx' 3 | import { copyElementContent } from 'utils/DOMHelper' 4 | import { getCodeElement } from './DOMHelper' 5 | 6 | const className = 'gitako-copy-file-button' 7 | export const copyFileButtonClassName = className 8 | 9 | const contents = { 10 | success: 'Success!', 11 | error: 'Copy failed!', 12 | normal: 'Copy file', 13 | } 14 | 15 | export function CopyFileButton() { 16 | const [content, setContent] = useState(contents.normal) 17 | useEffect(() => { 18 | if (content !== contents.normal) { 19 | const timer = setTimeout(() => { 20 | setContent(contents.normal) 21 | }, 1000) 22 | return () => clearTimeout(timer) 23 | } 24 | }, [content]) 25 | 26 | const elementRef = useRef(null) 27 | useEffect(() => { 28 | // Temporary fix: 29 | // React moved root node of event delegation since v17 30 | // onClick on won't work when rendered with `renderReact` 31 | const element = elementRef.current 32 | if (element) { 33 | const copyCode = () => { 34 | const codeElement = getCodeElement() 35 | if (codeElement) { 36 | setContent(copyElementContent(codeElement, true) ? contents.success : contents.error) 37 | } 38 | } 39 | element.addEventListener('click', copyCode) 40 | return () => element.removeEventListener('click', copyCode) 41 | } 42 | }, []) 43 | 44 | return ( 45 | 46 | {content} 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/platforms/GitHub/embeddedDataStructures.ts: -------------------------------------------------------------------------------- 1 | import * as s from 'superstruct' 2 | 3 | const repo = s.object({ 4 | id: s.number(), 5 | defaultBranch: s.string(), 6 | name: s.string(), 7 | ownerLogin: s.string(), 8 | currentUserCanPush: s.boolean(), 9 | isFork: s.boolean(), 10 | isEmpty: s.boolean(), 11 | createdAt: s.string(), 12 | ownerAvatar: s.string(), 13 | public: s.boolean(), 14 | private: s.boolean(), 15 | isOrgOwned: s.boolean(), 16 | }) 17 | 18 | const user = s.object({ 19 | id: s.number(), 20 | login: s.string(), 21 | userEmail: s.string(), 22 | }) 23 | 24 | const rel = s.object({ 25 | name: s.string(), 26 | listCacheKey: s.string(), 27 | canEdit: s.boolean(), 28 | refType: s.string(), 29 | currentOid: s.string(), 30 | }) 31 | 32 | const treeItem = s.object({ 33 | name: s.string(), 34 | path: s.string(), 35 | contentType: s.string(), 36 | }) 37 | 38 | const tree = s.object({ 39 | items: s.array(treeItem), 40 | templateDirectorySuggestionUrl: s.nullable(s.never()), 41 | readme: s.nullable(s.never()), 42 | totalCount: s.number(), 43 | showBranchInfobar: s.boolean(), 44 | }) 45 | 46 | const repoPayload = s.object({ 47 | allShortcutsEnabled: s.boolean(), 48 | path: s.string(), 49 | repo: repo, 50 | currentUser: user, 51 | refInfo: rel, 52 | tree: tree, 53 | fileTree: s.nullable(s.never()), 54 | fileTreeProcessingTime: s.nullable(s.never()), 55 | foldersToFetch: s.array(s.unknown()), 56 | treeExpanded: s.boolean(), 57 | symbolsExpanded: s.boolean(), 58 | isOverview: s.boolean(), 59 | overview: s.unknown(), 60 | }) 61 | 62 | const reposOverview = s.object({ 63 | props: s.object({ 64 | initialPayload: repoPayload, 65 | appPayload: s.unknown(), 66 | }), 67 | }) 68 | const app = s.object({ 69 | payload: repoPayload, 70 | }) 71 | 72 | export const embeddedDataStruct = { 73 | repo, 74 | user, 75 | rel, 76 | treeItem, 77 | tree, 78 | repoPayload, 79 | reposOverview, 80 | app, 81 | } 82 | -------------------------------------------------------------------------------- /src/platforms/GitHub/getCommitTreeData.ts: -------------------------------------------------------------------------------- 1 | import { formatID } from 'utils/DOMHelper' 2 | import * as API from './API' 3 | import { processTree } from './index' 4 | 5 | export async function getCommitTreeData( 6 | { userName, repoName }: Pick, 7 | commitSHA: string, 8 | accessToken: string | undefined, 9 | ) { 10 | const treeData = ( 11 | await API.getPaginatedData(page => 12 | API.requestCommitTreeData(userName, repoName, commitSHA, page, accessToken), 13 | ) 14 | ) 15 | .map(({ files }) => files) 16 | .flat() 17 | 18 | const documents = await API.getCommitPageDocuments(/* userName, repoName, commitSHA */) 19 | 20 | const getItemURL = (path: string) => { 21 | for (const doc of documents) { 22 | const id = doc.querySelector(`[data-path="${path}"]`)?.parentElement?.id 23 | if (id) return formatID(id) 24 | } 25 | } 26 | 27 | const root = processTree( 28 | treeData.map(item => ({ 29 | type: 'blob', 30 | path: item.filename, 31 | name: item.filename.split('/').pop() || '', 32 | url: getItemURL(item.filename) || item.blob_url, 33 | sha: item.patch, 34 | diff: { 35 | status: item.status, 36 | additions: item.additions, 37 | deletions: item.deletions, 38 | changes: item.changes, 39 | }, 40 | })), 41 | ) 42 | 43 | return { root } 44 | } 45 | -------------------------------------------------------------------------------- /src/platforms/GitHub/hooks/useEnterpriseStatBarStyleFix.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { $ } from 'utils/$' 3 | import * as DOMHelper from '../DOMHelper' 4 | import { GitHub } from '../index' 5 | 6 | // A dirty fix for putting Gitako components over GHE stat bar. 7 | // In normal cases the styles should be set via react render function return value, 8 | // but adding the logic for such uncommon case is not worth the maintenance cost. 9 | export function useEnterpriseStatBarStyleFix() { 10 | useEffect(() => { 11 | if (GitHub.isEnterprise()) { 12 | const enterpriseStatHeaderElement = DOMHelper.selectEnterpriseStatHeader() 13 | const zIndex = parseInt(enterpriseStatHeaderElement?.style.zIndex || '0', 10) 14 | if (!isNaN(zIndex) && zIndex) { 15 | $('.gitako-toggle-show-button-wrapper', e => (e.style.zIndex = `${zIndex + 1}`)) 16 | $('.gitako-side-bar-body-wrapper', e => (e.style.zIndex = `${zIndex + 1}`)) 17 | } 18 | } 19 | }, []) 20 | } 21 | -------------------------------------------------------------------------------- /src/platforms/GitHub/hooks/useGitHubAttachCopySnippetButton.ts: -------------------------------------------------------------------------------- 1 | import { platform } from 'platforms' 2 | import { useCallback, useEffect } from 'react' 3 | import { useAfterRedirect } from 'utils/hooks/useFastRedirect' 4 | import * as DOMHelper from '../DOMHelper' 5 | import { GitHub } from '../index' 6 | 7 | export function useGitHubAttachCopySnippetButton(copySnippetButton: boolean) { 8 | const attachCopySnippetButton = useCallback( 9 | function attachCopySnippetButton() { 10 | if (platform === GitHub && copySnippetButton) DOMHelper.attachCopySnippet() 11 | }, 12 | [copySnippetButton], 13 | ) 14 | useEffect(attachCopySnippetButton, [attachCopySnippetButton]) 15 | useAfterRedirect(attachCopySnippetButton) 16 | } 17 | -------------------------------------------------------------------------------- /src/platforms/Gitea/DOMHelper.ts: -------------------------------------------------------------------------------- 1 | import { raiseError } from 'analytics' 2 | import { $ } from 'utils/$' 3 | 4 | export function isInRepoPage() { 5 | const repoHeaderSelector = '.repo-header' 6 | return Boolean($(repoHeaderSelector)) 7 | } 8 | 9 | export function isInCodePage() { 10 | const branchListSelector = '.reference' 11 | return Boolean($(branchListSelector)) 12 | } 13 | 14 | export function getCurrentBranch() { 15 | const branchListSelector = '.reference' 16 | const branchButtonElement = $(branchListSelector) 17 | const branchNameElement = branchButtonElement?.querySelector('.text > strong') 18 | if (branchNameElement) { 19 | return branchNameElement.textContent 20 | } 21 | 22 | raiseError(new Error('cannot get current branch')) 23 | } 24 | -------------------------------------------------------------------------------- /src/platforms/Gitea/Request.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace GiteaAPI { 2 | type TreeItem = { 3 | path: string 4 | mode: string 5 | sha: string 6 | size: number 7 | url: string 8 | type: 'blob' | 'commit' | 'tree' 9 | } 10 | 11 | type TreeData = { 12 | sha: string 13 | truncated: boolean 14 | tree: TreeItem[] 15 | url: string 16 | } 17 | 18 | type MetaData = { 19 | name: string 20 | default_branch: string 21 | html_url: string 22 | owner: { 23 | login: string 24 | html_url: string 25 | } 26 | } 27 | 28 | type BlobData = { 29 | encoding: 'base64' | string 30 | sha: string 31 | content?: string 32 | size: number 33 | url: string 34 | } 35 | 36 | type OAuth = { 37 | access_token?: string 38 | scope?: string 39 | token_type?: string 40 | error_description?: string 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/platforms/Gitea/URLHelper.ts: -------------------------------------------------------------------------------- 1 | import { raiseError } from 'analytics' 2 | import { sanitizedLocation } from 'utils/URLHelper' 3 | 4 | export function parse(): Partial & { path: string[] } { 5 | const [ 6 | , 7 | // ignore content before the first '/' 8 | userName, 9 | repoName, 10 | type, 11 | ...path // should be [...branchName.split('/'), ...filePath.split('/')] 12 | ] = unescape(decodeURIComponent(sanitizedLocation.pathname)).split('/') 13 | return { 14 | userName, 15 | repoName, 16 | branchName: undefined, 17 | type, 18 | path, 19 | } 20 | } 21 | 22 | export function parseSHA() { 23 | const { type, path } = parse() 24 | return type === 'blob' || type === 'tree' ? path[0] : undefined 25 | } 26 | 27 | function isCommitPath(path: string[]) { 28 | return isCompleteCommitSHA(path[0]) 29 | } 30 | 31 | function isCompleteCommitSHA(sha?: string) { 32 | return typeof sha === 'string' && /^[abcdef0-9]{40}$/i.test(sha) 33 | } 34 | 35 | export function getCurrentPath(branchName = '') { 36 | const { path, type } = parse() 37 | if (type === 'blob' || type === 'tree') { 38 | if (isCommitPath(path)) { 39 | // path = commit-SHA/path/to/item 40 | path.shift() 41 | } else { 42 | // path = branch/name/path/to/item or HEAD/path/to/item 43 | // HEAD is not a valid branch name. Getting HEAD means being detached. 44 | if (path[0] === 'HEAD') path.shift() 45 | else { 46 | const splitBranchName = branchName.split('/') 47 | while (splitBranchName.length) { 48 | if ( 49 | splitBranchName[0] === path[0] || 50 | // Keep consuming as their heads are same 51 | (splitBranchName.length === 1 && splitBranchName[0].startsWith(path[0])) 52 | // This happens when visiting URLs like /blob/{commitSHA}/path/to/file 53 | // and {commitSHA} is shorter than we got from DOM 54 | ) { 55 | splitBranchName.shift() 56 | path.shift() 57 | } else { 58 | raiseError(new Error(`branch name and path prefix not match`), { 59 | branchName, 60 | path: parse().path, 61 | }) 62 | return [] 63 | } 64 | } 65 | } 66 | } 67 | return path.map(decodeURIComponent) 68 | } 69 | return [] 70 | } 71 | -------------------------------------------------------------------------------- /src/platforms/Gitee/Request.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace GiteeAPI { 2 | type TreeItem = { 3 | path: string 4 | mode: string 5 | sha: string 6 | size: number 7 | url: string 8 | type: 'blob' | 'commit' | 'tree' 9 | } 10 | 11 | type TreeData = { 12 | sha: string 13 | truncated: boolean 14 | tree: TreeItem[] 15 | url: string 16 | } 17 | 18 | type MetaData = { 19 | default_branch: string 20 | html_url: string 21 | parent: { 22 | url: string 23 | } 24 | } 25 | 26 | type BlobData = { 27 | encoding: 'base64' | string 28 | sha: string 29 | content?: string 30 | size: number 31 | url: string 32 | } 33 | 34 | type OAuth = { 35 | access_token?: string 36 | scope?: string 37 | token_type?: string 38 | error_description?: string 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/platforms/Gitee/URLHelper.ts: -------------------------------------------------------------------------------- 1 | import { raiseError } from 'analytics' 2 | import { sanitizedLocation } from 'utils/URLHelper' 3 | 4 | export function parse(): Partial & { path: string[] } { 5 | const [ 6 | , 7 | // ignore content before the first '/' 8 | userName, 9 | repoName, 10 | type, 11 | ...path // should be [...branchName.split('/'), ...filePath.split('/')] 12 | ] = unescape(decodeURIComponent(sanitizedLocation.pathname)).split('/') 13 | return { 14 | userName, 15 | repoName, 16 | branchName: undefined, 17 | type, 18 | path, 19 | } 20 | } 21 | 22 | export function parseSHA() { 23 | const { type, path } = parse() 24 | return type === 'blob' || type === 'tree' ? path[0] : undefined 25 | } 26 | 27 | function isCommitPath(path: string[]) { 28 | return isCompleteCommitSHA(path[0]) 29 | } 30 | 31 | function isCompleteCommitSHA(sha?: string) { 32 | return typeof sha === 'string' && /^[abcdef0-9]{40}$/i.test(sha) 33 | } 34 | 35 | export function getCurrentPath(branchName = '') { 36 | const { path, type } = parse() 37 | if (type === 'blob' || type === 'tree') { 38 | if (isCommitPath(path)) { 39 | // path = commit-SHA/path/to/item 40 | path.shift() 41 | } else { 42 | // path = branch/name/path/to/item or HEAD/path/to/item 43 | // HEAD is not a valid branch name. Getting HEAD means being detached. 44 | if (path[0] === 'HEAD') path.shift() 45 | else { 46 | const splitBranchName = branchName.split('/') 47 | while (splitBranchName.length) { 48 | if ( 49 | splitBranchName[0] === path[0] || 50 | // Keep consuming as their heads are same 51 | (splitBranchName.length === 1 && splitBranchName[0].startsWith(path[0])) 52 | // This happens when visiting URLs like /blob/{commitSHA}/path/to/file 53 | // and {commitSHA} is shorter than we got from DOM 54 | ) { 55 | splitBranchName.shift() 56 | path.shift() 57 | } else { 58 | raiseError(new Error(`branch name and path prefix not match`), { 59 | branchName, 60 | path: parse().path, 61 | }) 62 | return [] 63 | } 64 | } 65 | } 66 | } 67 | return path.map(decodeURIComponent) 68 | } 69 | return [] 70 | } 71 | -------------------------------------------------------------------------------- /src/platforms/dummyPlatformForTypeSafety.ts: -------------------------------------------------------------------------------- 1 | export const dummyPlatformForTypeSafety: Platform = { 2 | isEnterprise() { 3 | return false 4 | }, 5 | resolvePartialMetaData() { 6 | return null 7 | }, 8 | getDefaultBranchName: dummyPlatformMethod, 9 | resolveUrlFromMetaData: dummyPlatformMethod, 10 | getTreeData: dummyPlatformMethod, 11 | shouldExpandSideBar() { 12 | return false 13 | }, 14 | getCurrentPath: dummyPlatformMethod, 15 | setOAuth: dummyPlatformMethod, 16 | getOAuthLink: dummyPlatformMethod, 17 | } 18 | 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | function dummyPlatformMethod(): any { 21 | throw new Error(`Do not call dummy platform methods`) 22 | } 23 | -------------------------------------------------------------------------------- /src/platforms/index.ts: -------------------------------------------------------------------------------- 1 | import { forOf } from 'utils/general' 2 | import { dummyPlatformForTypeSafety } from './dummyPlatformForTypeSafety' 3 | import { Gitea } from './Gitea' 4 | import { Gitee } from './Gitee' 5 | import { GitHub } from './GitHub' 6 | 7 | const platforms = { 8 | GitHub, 9 | Gitee, 10 | Gitea, 11 | } 12 | 13 | function resolvePlatform() { 14 | return ( 15 | forOf(platforms, (platformName, platform) => { 16 | const { shouldActivate = () => !!platform.resolvePartialMetaData() } = platform 17 | if (shouldActivate()) return platform 18 | }) || dummyPlatformForTypeSafety 19 | ) 20 | } 21 | 22 | function getPlatformName() { 23 | return forOf(platforms, (name, $platform) => { 24 | if (platform === $platform) return name 25 | }) 26 | } 27 | 28 | export const platform = resolvePlatform() 29 | export const platformName = getPlatformName() 30 | 31 | export const errors = { 32 | SERVER_FAULT: 'Server Fault', 33 | NOT_FOUND: 'Repo Not Found', 34 | BAD_CREDENTIALS: 'Bad credentials', 35 | API_RATE_LIMIT: 'API rate limit', 36 | EMPTY_PROJECT: 'Empty project', 37 | BLOCKED_PROJECT: 'Blocked project', 38 | CONNECTION_BLOCKED: 'Connection blocked', 39 | } 40 | -------------------------------------------------------------------------------- /src/platforms/platform.d.ts: -------------------------------------------------------------------------------- 1 | type Platform = { 2 | shouldActivate?(): boolean 3 | isEnterprise(): boolean 4 | // branch name might not be available when resolving from DOM and URL 5 | resolvePartialMetaData(): PartialMetaData | null 6 | // resolveMetaData(metaData: PartialMetaData, accessToken?: string): Async 7 | getDefaultBranchName( 8 | metaData: Pick, 9 | accessToken?: string, 10 | ): Promise 11 | resolveUrlFromMetaData(metaData: MetaData): { 12 | userUrl: string 13 | repoUrl: string 14 | branchUrl: string 15 | } 16 | getTreeData( 17 | metaData: Pick, 18 | path?: string, 19 | recursive?: boolean, 20 | accessToken?: string, 21 | ): Promise<{ root: TreeNode; defer?: boolean }> 22 | shouldExpandSideBar(): boolean 23 | shouldExpandAll?(): boolean 24 | getCurrentPath(branchName: string): string[] | null 25 | setOAuth(code: string): Promise 26 | getOAuthLink(): string 27 | delegateFastRedirectAnchorProps?(options?: { node?: TreeNode }): 28 | | (React.DOMAttributes & Record) // support data-* attributes 29 | | void 30 | loadWithFastRedirect?(url: string, element: HTMLElement): boolean | void 31 | usePlatformHooks?(): void 32 | mapErrorMessage?: (error: Error) => string | void 33 | } 34 | -------------------------------------------------------------------------------- /src/react-override.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // Patch the removed `children` prop 3 | declare namespace React { 4 | interface FunctionComponent

{ 5 | (props: PropsWithChildren

, context?: any): ReactElement | null 6 | propTypes?: WeakValidationMap

| undefined 7 | contextTypes?: ValidationMap | undefined 8 | defaultProps?: Partial

| undefined 9 | displayName?: string | undefined 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/clippy.scss: -------------------------------------------------------------------------------- 1 | @mixin clippy { 2 | .clippy-wrapper { 3 | position: relative; 4 | width: 0; 5 | height: 0; 6 | top: 8px; 7 | left: calc(100% - 40px); 8 | z-index: 1; 9 | 10 | .clippy { 11 | width: 32px; 12 | height: 32px; 13 | border: 1px solid var(--color-border-default); 14 | border-radius: 4px; 15 | 16 | @include interactive-background; 17 | .icon { 18 | width: 100%; 19 | height: 100%; 20 | display: block; 21 | background-image: url('~@primer/octicons-react/build/svg/copy-16.svg?inline'); 22 | background-position: center; 23 | background-repeat: no-repeat; 24 | &.success { 25 | background-image: url('~@primer/octicons-react/build/svg/check-16.svg?inline'); 26 | } 27 | &.fail { 28 | background-image: url('~@primer/octicons-react/build/svg/x-16.svg?inline'); 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | // TODO: use react to render the button content and set color with CSS variables 36 | @media (prefers-color-scheme: dark) { 37 | :root[data-color-mode='auto'] .markdown-body .clippy .icon { 38 | filter: invert(0.7); // hack to make it looks like a normal color :P 39 | } 40 | } 41 | :root[data-color-mode='dark'] { 42 | .markdown-body .clippy .icon { 43 | filter: invert(0.7); // hack to make it looks like a normal color :P 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/styles/code-folding.scss: -------------------------------------------------------------------------------- 1 | @mixin code-folding { 2 | .blob-wrapper table .blob-num { 3 | position: relative; // for positioning 4 | min-width: 60px; 5 | padding-right: 20px; 6 | } 7 | 8 | // hide code fold handler if not enabled 9 | .gitako-code-fold-handler { 10 | display: none; 11 | } 12 | 13 | .gitako-code-fold-attached:not(.gitako-code-fold-attached-disabled) { 14 | tr { 15 | .gitako-code-fold-handler { 16 | @include hide-for-print(); 17 | 18 | display: initial; 19 | position: absolute; 20 | top: 0px; 21 | right: 0px; 22 | width: 20px; 23 | height: 20px; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | 28 | &::before { 29 | width: 16px; 30 | height: 20px; 31 | transition: 0.25s ease; 32 | @include pseudo-primer-icon('chevron-down-16'); 33 | } 34 | 35 | @include interactive-background-on-before( 36 | var(--color-fg-subtle), 37 | var(--color-fg-default), 38 | var(--color-fg-muted) 39 | ); 40 | } 41 | 42 | &.gitako-code-fold-active { 43 | background-color: var(--color-neutral-muted); 44 | 45 | .gitako-code-fold-handler { 46 | &::before { 47 | transform: rotate(-90deg); 48 | } 49 | 50 | @include interactive-background-on-before( 51 | var(--color-fg-muted), 52 | var(--color-fg-default), 53 | var(--color-fg-subtle) 54 | ); 55 | } 56 | 57 | .blob-code::after { 58 | color: var(--color-fg-muted); 59 | content: '⋯'; 60 | margin: 0.1em 0.2em 0px; 61 | } 62 | } 63 | 64 | // hide folded sections, except for print 65 | &.gitako-code-fold-hidden { 66 | @media screen { 67 | display: none; 68 | } 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/styles/gitee.scss: -------------------------------------------------------------------------------- 1 | $gitee-header-z-index: 1002; 2 | 3 | @mixin gitee($name) { 4 | [data-gitako-platform='Gitee'] { 5 | &[data-#{$name}-ready='true'] { 6 | // reset styles 7 | .#{$name}-side-bar { 8 | input[type='text'], 9 | input[type='password'], 10 | .ui-autocomplete-input, 11 | textarea, 12 | .uneditable-input { 13 | padding: initial; 14 | border: none; 15 | } 16 | } 17 | 18 | padding-top: 0; 19 | 20 | .#{$name}-side-bar { 21 | h1, 22 | h2, 23 | h3, 24 | h4, 25 | h5, 26 | h6 { 27 | margin: 0; 28 | } 29 | } 30 | } 31 | 32 | &[data-with-gitako-spacing='left'] { 33 | body { 34 | width: auto; // shrink width 35 | .site-content { 36 | min-width: 1040px; 37 | } 38 | } 39 | 40 | &[data-with-gitako-spacing='right'] { 41 | body { 42 | min-width: calc(1040px + var(--gitako-width)); 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/styles/github.scss: -------------------------------------------------------------------------------- 1 | @mixin github($github-content-width) { 2 | @media (min-width: $github-content-width) { 3 | [data-gitako-platform='GitHub'] { 4 | body { 5 | min-width: $github-content-width; 6 | } 7 | 8 | &[data-with-gitako-spacing='left'], 9 | &[data-with-gitako-spacing='right'] { 10 | #files-bucket, 11 | .files-bucket { 12 | & > .position-relative.px-4 { 13 | width: unset !important; 14 | left: unset !important; 15 | right: unset !important; 16 | margin-left: unset !important; 17 | margin-right: unset !important; 18 | } 19 | } 20 | } 21 | 22 | &[data-with-gitako-spacing='right'] { 23 | body { 24 | min-width: auto; // it will break layout when too narrow, but at least it makes everything visible 25 | 26 | .container-xl { 27 | margin-left: max( 28 | 0, 29 | calc((100vw - 1280px - var(--gitako-width)) / 2) 30 | ); // override margin-left: auto; 31 | margin-right: max( 32 | 0, 33 | calc((100vw - 1280px - var(--gitako-width)) / 2) 34 | ); // override margin-right: auto; 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/styles/keyframes.scss: -------------------------------------------------------------------------------- 1 | @keyframes rotate { 2 | from { 3 | transform: rotateZ(0); 4 | } 5 | to { 6 | transform: rotateZ(360deg); 7 | } 8 | } 9 | 10 | @keyframes pulse-rotate { 11 | 0% { 12 | transform: rotateZ(0); 13 | } 14 | 20% { 15 | transform: rotateZ(190deg); 16 | } 17 | 30% { 18 | transform: rotateZ(175deg); 19 | } 20 | 50% { 21 | transform: rotateZ(180deg); 22 | } 23 | 70% { 24 | transform: rotateZ(370deg); 25 | } 26 | 80% { 27 | transform: rotateZ(355deg); 28 | } 29 | 100% { 30 | transform: rotateZ(360deg); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/styles/layout.scss: -------------------------------------------------------------------------------- 1 | @mixin flex($justify: center, $align: center, $inline: true) { 2 | @if $inline { 3 | display: inline-flex; 4 | } @else { 5 | display: flex; 6 | } 7 | 8 | @if $justify { 9 | justify-content: $justify; 10 | } 11 | @if $align { 12 | align-items: $align; 13 | } 14 | } 15 | 16 | @mixin flex-center { 17 | @include flex(); 18 | } 19 | -------------------------------------------------------------------------------- /src/styles/primer-like.scss: -------------------------------------------------------------------------------- 1 | // primer-like styles 2 | @import './themes.scss'; 3 | 4 | @mixin interactive-frame() { 5 | @include interactive-border; 6 | @include interactive-background; 7 | } 8 | 9 | @mixin interactive-border( 10 | $default: var(--color-btn-border), 11 | $hover: var(--color-btn-hover-border), 12 | $active: var(--color-btn-active-border), 13 | $focus: $hover 14 | ) { 15 | border: 1px solid $default; 16 | &:hover { 17 | border: 1px solid $hover; 18 | } 19 | &:focus { 20 | border: 1px solid $focus; 21 | } 22 | &:active { 23 | border: 1px solid $active; 24 | } 25 | } 26 | 27 | @mixin interactive-background( 28 | $default: var(--color-btn-bg), 29 | $hover: var(--color-btn-hover-bg), 30 | $active: var(--color-btn-active-bg), 31 | $focus: $hover 32 | ) { 33 | background-color: $default; 34 | &:hover { 35 | background-color: $hover; 36 | } 37 | &:focus { 38 | background-color: $focus; 39 | } 40 | &:active { 41 | background-color: $active; 42 | } 43 | } 44 | 45 | @mixin interactive-background-on-before($default, $hover, $active, $focus: $hover) { 46 | &::before { 47 | background-color: $default; 48 | } 49 | &:hover { 50 | &::before { 51 | background-color: $hover; 52 | } 53 | } 54 | &:active { 55 | &::before { 56 | background-color: $active; 57 | } 58 | } 59 | &:hover { 60 | &::before { 61 | background-color: $hover; 62 | } 63 | } 64 | } 65 | 66 | @mixin pseudo-primer-icon($icon-name) { 67 | content: ''; 68 | display: block; 69 | cursor: pointer; 70 | user-select: none; 71 | -webkit-mask-image: url('~@primer/octicons-react/build/svg/' + $icon-name + '.svg?inline'); 72 | mask-image: url('~@primer/octicons-react/build/svg/' + $icon-name + '.svg?inline'); 73 | -webkit-mask-size: contain; 74 | mask-size: contain; 75 | -webkit-mask-position: center; 76 | mask-position: center; 77 | } 78 | 79 | @mixin icon-button( 80 | $default: transparent, 81 | $hover: var(--color-btn-hover-bg), 82 | $active: var(--color-btn-focus-bg), 83 | $focus: $hover 84 | ) { 85 | @include flex-center(); 86 | cursor: pointer; 87 | padding: 0; 88 | border: none; 89 | 90 | @include interactive-background($default, $hover, $active, $focus); 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/$.ts: -------------------------------------------------------------------------------- 1 | export function $(selector: string): E | null 2 | export function $(selector: string, existCallback: (element: HTMLElement) => R1): R1 | null 3 | export function $( 4 | selector: string, 5 | existCallback: (element: HTMLElement) => R1, 6 | otherwise: () => R2, 7 | ): R1 | R2 8 | export function $( 9 | selector: string, 10 | existCallback: undefined | null, 11 | otherwise: () => R2, 12 | ): E | R2 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | export function $(selector: string, existCallback?: any, otherwise?: any) { 15 | const element = document.querySelector(selector) 16 | if (element) { 17 | return existCallback ? existCallback(element) : element 18 | } 19 | return otherwise ? otherwise() : null 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/EventHub.ts: -------------------------------------------------------------------------------- 1 | export class EventSubscription = VoidFN> { 2 | listeners: Listener[] = [] 3 | 4 | emit(data: Data) { 5 | for (const listener of this.listeners) listener(data) 6 | } 7 | 8 | addEventListener(listener: Listener) { 9 | this.listeners.push(listener) 10 | return () => this.removeEventListener(listener) 11 | } 12 | 13 | removeEventListener(listener: Listener) { 14 | const index = this.listeners.indexOf(listener) 15 | if (index !== -1) this.listeners.splice(index, 1) 16 | } 17 | } 18 | 19 | export class EventHub< 20 | Shape extends { 21 | [event: string]: unknown 22 | }, 23 | > { 24 | ports: { 25 | [key in keyof Shape]: EventSubscription 26 | } = {} as EventHub['ports'] 27 | 28 | getPort(event: Event) { 29 | if (!this.ports[event]) this.ports[event] = new EventSubscription() 30 | 31 | return this.ports[event] 32 | } 33 | 34 | emit(event: Event, data: Shape[Event]) { 35 | const port = this.getPort(event) 36 | return port.emit(data) 37 | } 38 | 39 | addEventListener( 40 | event: Event, 41 | listener: (data: Shape[Event]) => void, 42 | ) { 43 | const port = this.getPort(event) 44 | return port.addEventListener(listener) 45 | } 46 | 47 | removeEventListener( 48 | event: Event, 49 | listener: (data: Shape[Event]) => void, 50 | ) { 51 | const port = this.getPort(event) 52 | return port.removeEventListener(listener) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/URLHelper.ts: -------------------------------------------------------------------------------- 1 | export const sanitizedLocation = { 2 | get href() { 3 | const { href, pathname } = window.location 4 | const hasDupSlashes = pathname.includes('//') 5 | const needSanitize = hasDupSlashes 6 | if (!needSanitize) return href 7 | 8 | const url = new URL(href) 9 | url.pathname = sanitizedLocation.pathname 10 | return url.href 11 | }, 12 | get pathname() { 13 | const { pathname } = window.location 14 | // remove duplicated slashes 15 | return pathname.replace(/^\/\/+/g, '/') 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/VisibleNodesGenerator/BaseLayer.ts: -------------------------------------------------------------------------------- 1 | import { EventHub } from '../EventHub' 2 | import { findNode } from '../general' 3 | import { Options } from './index' 4 | 5 | function mergeNodes(target: TreeNode, source: TreeNode) { 6 | for (const node of source.contents || []) { 7 | const dup = target.contents?.find($node => $node.path === node.path) 8 | if (dup) { 9 | mergeNodes(dup, node) 10 | } else { 11 | if (!target.contents) target.contents = [] 12 | target.contents.push(node) 13 | } 14 | } 15 | } 16 | 17 | export class BaseLayer { 18 | baseRoot: TreeNode 19 | getTreeData: (path: string) => Async 20 | loading: Set = new Set() 21 | defer: boolean 22 | 23 | baseHub = new EventHub<{ 24 | emit: BaseLayer['baseRoot'] 25 | loadingChange: BaseLayer['loading'] 26 | }>() 27 | 28 | constructor({ root, getTreeData, defer = false }: Options) { 29 | this.baseRoot = root 30 | this.getTreeData = getTreeData 31 | this.defer = defer 32 | } 33 | 34 | loadTreeData = async (path: string) => { 35 | const node = await findNode(this.baseRoot, path) 36 | if (node && node.type !== 'tree') return node 37 | if (node?.contents?.length) return node // check in memory 38 | if (this.loading.has(path)) return 39 | 40 | this.loading.add(path) 41 | this.baseHub.emit('loadingChange', this.loading) 42 | mergeNodes(this.baseRoot, await this.getTreeData(path)) 43 | this.loading.delete(path) 44 | this.baseHub.emit('loadingChange', this.loading) 45 | this.baseHub.emit('emit', this.baseRoot) 46 | 47 | return await findNode(this.baseRoot, path) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/VisibleNodesGenerator/CompressLayer.ts: -------------------------------------------------------------------------------- 1 | import { EventHub } from '../EventHub' 2 | import { withEffect } from '../general' 3 | import { Options } from './index' 4 | import { ShakeLayer } from './ShakeLayer' 5 | 6 | function compressTree(root: TreeNode, prefix: string[] = []): TreeNode { 7 | if (root.contents) { 8 | if (root.contents.length === 1) { 9 | const [singleton] = root.contents 10 | if (singleton.type === 'tree') { 11 | return compressTree(singleton, [...prefix, root.name]) 12 | } 13 | } 14 | 15 | let compressed = false 16 | const contents = [] 17 | for (const node of root.contents) { 18 | const $node = compressTree(node) 19 | if ($node !== node) compressed = true 20 | contents.push($node) 21 | } 22 | if (compressed) 23 | return { 24 | ...root, 25 | name: [...prefix, root.name].join('/'), 26 | contents, 27 | } 28 | } 29 | return prefix.length 30 | ? { 31 | ...root, 32 | name: [...prefix, root.name].join('/'), 33 | } 34 | : root 35 | } 36 | 37 | export class CompressLayer extends ShakeLayer { 38 | private compress: boolean 39 | depths = new Map() 40 | compressedRoot: TreeNode | null = null 41 | compressHub = new EventHub<{ emit: TreeNode | null }>() 42 | 43 | constructor(options: Options) { 44 | super(options) 45 | this.compress = Boolean(options.compress) 46 | 47 | this.shakeHub.addEventListener('emit', () => this.compressTree()) 48 | } 49 | 50 | setCompression(compress: CompressLayer['compress']) { 51 | this.compress = compress 52 | this.compressTree() 53 | } 54 | 55 | private compressTree = withEffect( 56 | () => { 57 | this.compressedRoot = 58 | this.compress && this.shackedRoot 59 | ? { 60 | ...this.shackedRoot, 61 | contents: this.shackedRoot.contents?.map(node => compressTree(node)), 62 | } 63 | : this.shackedRoot 64 | 65 | if (this.compressedRoot) { 66 | const depths = new Map() 67 | const recordDepth = (node: TreeNode, depth = 0) => { 68 | depths.set(node, depth) 69 | for (const $node of node.contents || []) { 70 | recordDepth($node, depth + 1) 71 | } 72 | } 73 | recordDepth(this.compressedRoot, -1) 74 | this.depths = depths 75 | } 76 | }, 77 | () => this.compressHub.emit('emit', this.compressedRoot), 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/utils/VisibleNodesGenerator/ShakeLayer.ts: -------------------------------------------------------------------------------- 1 | import { EventHub } from '../EventHub' 2 | import { withEffect } from '../general' 3 | import { BaseLayer } from './BaseLayer' 4 | import { Options, SearchParams } from './index' 5 | 6 | function search( 7 | root: TreeNode, 8 | match: (node: TreeNode) => boolean, 9 | onChildMatch: (node: TreeNode) => void, 10 | ): TreeNode | null { 11 | // go traverse no matter whether root matches to make sure find & expand the related nodes 12 | // The `related nodes` are the nodes that either itself matches or any of direct or indirect children match 13 | const contents = [] 14 | 15 | if (root.type === 'tree' && root.contents) { 16 | for (const node of root.contents) { 17 | const $node = search(node, match, onChildMatch) 18 | if ($node) contents.push($node) 19 | } 20 | 21 | if (contents.length) onChildMatch(root) 22 | } 23 | 24 | // Return root if itself matches 25 | if (match(root)) return root 26 | 27 | // Otherwise, but when deeper nodes match, return partial root 28 | if (contents.length) { 29 | return { 30 | ...root, 31 | contents, 32 | } 33 | } 34 | 35 | return null 36 | } 37 | 38 | export class ShakeLayer extends BaseLayer { 39 | shackedRoot: TreeNode | null = this.baseRoot 40 | lastSearchParams: SearchParams | null = null 41 | shakeHub = new EventHub<{ emit: TreeNode | null }>() 42 | 43 | get isSearching() { 44 | return this.lastSearchParams !== null 45 | } 46 | 47 | constructor(options: Options) { 48 | super(options) 49 | 50 | this.baseHub.addEventListener('emit', () => this.shake(this.lastSearchParams)) 51 | } 52 | 53 | shake = withEffect( 54 | (searchParams: ShakeLayer['lastSearchParams']) => { 55 | this.lastSearchParams = searchParams 56 | 57 | let root: ShakeLayer['shackedRoot'] = this.baseRoot 58 | if (searchParams) { 59 | const { matchNode, onChildMatch } = searchParams 60 | root = search(this.baseRoot, matchNode, onChildMatch) 61 | } 62 | this.shackedRoot = root 63 | }, 64 | () => this.shakeHub.emit('emit', this.shackedRoot), 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/VisibleNodesGenerator/VisibleNodesGenerator.test.ts: -------------------------------------------------------------------------------- 1 | import { VisibleNodes, VisibleNodesGenerator } from '.' 2 | import { searchModes } from '../../components/searchModes/index' 3 | import { prepareVisibleNodes } from './prepare' 4 | import searchResult__json from './searchResults/json.json' 5 | import treeRoot from './treeData.json' 6 | 7 | const compressSingletonFolder = true 8 | const restoreExpandedFolders = true 9 | 10 | const visibleNodesGenerator = new VisibleNodesGenerator({ 11 | root: treeRoot as TreeNode, 12 | compress: compressSingletonFolder, 13 | async getTreeData() { 14 | throw new Error(`\`getTreeData\` should not be called`) 15 | }, 16 | }) 17 | 18 | const search = (searchKey: string, searchMode: 'regex' | 'fuzzy') => 19 | new Promise(resolve => { 20 | const cancel = visibleNodesGenerator.onUpdate(visibleNodes => { 21 | cancel() 22 | resolve(visibleNodes) 23 | }) 24 | visibleNodesGenerator.search( 25 | searchModes[searchMode].getSearchParams(searchKey), 26 | restoreExpandedFolders, 27 | ) 28 | }) 29 | 30 | // In Jest, `it`s does not handle async flow as expected, 31 | // there will be errors if the `it`s are in the same `describe`. 32 | // But `describes` run in sequence. 33 | 34 | describe('VisibleNodesGenerator Regex Search', () => { 35 | it('finds all nodes when searching regexp /./', async () => { 36 | for (const node of visibleNodesGenerator.visibleNodes.nodes) { 37 | await visibleNodesGenerator.toggleExpand(node, true) 38 | } 39 | const initialVisibleNodes = visibleNodesGenerator.visibleNodes 40 | expect(prepareVisibleNodes(await search('.', 'regex')).nodes).toEqual( 41 | prepareVisibleNodes(initialVisibleNodes).nodes, 42 | ) 43 | }) 44 | }) 45 | 46 | describe('VisibleNodesGenerator Fuzzy Search', () => { 47 | it('finds correct nodes when searching path `json`', async () => { 48 | expect(prepareVisibleNodes(await search('json', 'fuzzy'))).toEqual(searchResult__json) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/utils/VisibleNodesGenerator/index.ts: -------------------------------------------------------------------------------- 1 | import { EventHub } from '../EventHub' 2 | import { BaseLayer } from './BaseLayer' 3 | import { CompressLayer } from './CompressLayer' 4 | import { FlattenLayer } from './FlattenLayer' 5 | 6 | export type SearchParams = { 7 | matchNode(node: TreeNode): boolean 8 | onChildMatch(node: TreeNode): void 9 | } 10 | 11 | export type Options = { 12 | root: BaseLayer['baseRoot'] 13 | defer?: BaseLayer['defer'] 14 | getTreeData: BaseLayer['getTreeData'] 15 | compress: CompressLayer['compress'] 16 | } 17 | 18 | export type VisibleNodes = { 19 | loading: BaseLayer['loading'] 20 | depths: CompressLayer['depths'] 21 | nodes: FlattenLayer['nodes'] 22 | expandedNodes: FlattenLayer['expandedNodes'] 23 | focusedNode: FlattenLayer['focusedNode'] 24 | } 25 | 26 | export class VisibleNodesGenerator extends FlattenLayer { 27 | hub = new EventHub<{ 28 | emit: VisibleNodes 29 | }>() 30 | 31 | constructor(options: Options) { 32 | super(options) 33 | 34 | this.flattenHub.addEventListener('emit', () => this.update()) 35 | this.baseHub.addEventListener('loadingChange', () => this.update()) 36 | 37 | this.search(null) 38 | } 39 | 40 | onUpdate(callback: (visibleNodes: VisibleNodes) => void) { 41 | return this.hub.addEventListener('emit', callback) 42 | } 43 | 44 | onNextUpdate(callback: (visibleNodes: VisibleNodes) => void) { 45 | const oneTimeSubscription = (visibleNodes: VisibleNodes) => { 46 | cancel() 47 | callback(visibleNodes) 48 | } 49 | const cancel = this.hub.addEventListener('emit', oneTimeSubscription) 50 | return cancel 51 | } 52 | 53 | update() { 54 | this.hub.emit('emit', this.visibleNodes) 55 | } 56 | 57 | get visibleNodes(): VisibleNodes { 58 | return { 59 | nodes: this.nodes, 60 | depths: this.depths, 61 | expandedNodes: this.expandedNodes, 62 | focusedNode: this.focusedNode, 63 | loading: this.loading, 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | export function assert(condition: boolean, errorMessage?: string): asserts condition { 2 | if (!condition) throw new Error(errorMessage) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/config/migrations/1.0.1.ts: -------------------------------------------------------------------------------- 1 | import { is } from 'utils/is' 2 | import { storageHelper } from 'utils/storageHelper' 3 | import { Migration } from '.' 4 | import { Storage } from '../../storageHelper' 5 | 6 | export const migration: Migration = { 7 | version: '1.0.1', 8 | async migrate(version) { 9 | const config: JSONObject | void = await storageHelper.get([ 10 | 'configVersion', 11 | 'sideBarWidth', 12 | 'shortcut', 13 | 'access_token', 14 | 'compressSingletonFolder', 15 | 'copyFileButton', 16 | 'copySnippetButton', 17 | 'intelligentToggle', 18 | 'icons', 19 | ]) 20 | if ( 21 | config && 22 | (!('configVersion' in config) || 23 | config.configVersion === null || 24 | !is.string(config.configVersion) || 25 | config.configVersion < version) 26 | ) { 27 | await storageHelper.set({ platform_GitHub: config, configVersion: version }) 28 | } 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/config/migrations/1.3.4.ts: -------------------------------------------------------------------------------- 1 | import { is } from 'utils/is' 2 | import { storageHelper } from 'utils/storageHelper' 3 | import { Migration } from '.' 4 | import { Config, VersionedConfig } from '../helper' 5 | 6 | export const migration: Migration = { 7 | version: '1.3.4', 8 | async migrate(version) { 9 | const config: JSONObject | void = await storageHelper.get>([ 10 | 'configVersion', 11 | 'platform_undefined', 12 | 'platform_GitHub', 13 | 'platform_github.com', 14 | ]) 15 | if ( 16 | config && 17 | 'configVersion' in config && 18 | is.string(config.configVersion) && 19 | config.configVersion < version && 20 | (config.platform_GitHub || config.platform_undefined) && 21 | !config['platform_github.com'] 22 | ) { 23 | await storageHelper.set({ 24 | ['platform_github.com']: config.platform_GitHub || config.platform_undefined, 25 | configVersion: version, 26 | }) 27 | } 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/config/migrations/2.6.0.ts: -------------------------------------------------------------------------------- 1 | import { storageHelper } from 'utils/storageHelper' 2 | import { Migration, onConfigOutdated } from '.' 3 | 4 | export const migration: Migration = { 5 | version: '2.6.0', 6 | async migrate(version) { 7 | type ConfigBeforeMigrate = { 8 | access_token?: string 9 | } 10 | type ConfigAfterMigrate = { 11 | accessToken?: string 12 | } 13 | 14 | await onConfigOutdated(version, async configs => { 15 | for (const key of Object.keys(configs)) { 16 | const target = configs[key] 17 | if (typeof target === 'object' && target !== null && 'access_token' in target) { 18 | const configBeforeMigrate: ConfigBeforeMigrate = target 19 | const { access_token: accessToken, ...rest } = configBeforeMigrate 20 | const configAfterMigrate: ConfigAfterMigrate = { 21 | ...rest, 22 | accessToken, 23 | } 24 | await storageHelper.set({ 25 | [key]: configAfterMigrate, 26 | }) 27 | } 28 | } 29 | }) 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/config/migrations/3.0.0.ts: -------------------------------------------------------------------------------- 1 | import { storageHelper } from 'utils/storageHelper' 2 | import { Migration, onConfigOutdated } from '.' 3 | 4 | export const migration: Migration = { 5 | version: '3.0.0', 6 | async migrate(version) { 7 | // disable copy snippet button for github.com 8 | type ConfigBeforeMigrate = { 9 | copySnippetButton: boolean 10 | } 11 | type ConfigAfterMigrate = { 12 | copySnippetButton: boolean 13 | } 14 | 15 | await onConfigOutdated(version, async configs => { 16 | const key = 'platform_github.com' 17 | const config = configs[key] 18 | if (typeof config === 'object' && config !== null && 'copySnippetButton' in config) { 19 | const configBeforeMigrate = config as ConfigBeforeMigrate 20 | const { copySnippetButton, ...rest } = configBeforeMigrate 21 | if (copySnippetButton) { 22 | const configAfterMigrate: ConfigAfterMigrate = { 23 | ...rest, 24 | copySnippetButton: false, 25 | } 26 | await storageHelper.set({ 27 | [key]: configAfterMigrate, 28 | }) 29 | } 30 | } 31 | }) 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/config/migrations/3.13.1.ts: -------------------------------------------------------------------------------- 1 | import { storageHelper } from 'utils/storageHelper' 2 | import { Migration, onConfigOutdated } from '.' 3 | 4 | export const migration: Migration = { 5 | version: '3.13.1', 6 | async migrate(version) { 7 | // disable copy snippet button for github.com 8 | type ConfigBeforeMigrate = { 9 | toggleButtonVerticalDistance: number 10 | } 11 | type ConfigAfterMigrate = { 12 | toggleButtonVerticalDistance: number 13 | } 14 | 15 | await onConfigOutdated(version, async configs => { 16 | const key = 'platform_github.com' 17 | const config = configs[key] 18 | if ( 19 | typeof config === 'object' && 20 | config !== null && 21 | 'toggleButtonVerticalDistance' in config 22 | ) { 23 | const configBeforeMigrate = config as ConfigBeforeMigrate 24 | const { toggleButtonVerticalDistance, ...rest } = configBeforeMigrate 25 | const previousDefaultToggleButtonVerticalDistance = 124 26 | const newDefaultToggleButtonVerticalDistance = 64 27 | if (toggleButtonVerticalDistance === previousDefaultToggleButtonVerticalDistance) { 28 | const configAfterMigrate: ConfigAfterMigrate = { 29 | ...rest, 30 | toggleButtonVerticalDistance: newDefaultToggleButtonVerticalDistance, 31 | } 32 | await storageHelper.set({ 33 | [key]: configAfterMigrate, 34 | }) 35 | } 36 | } 37 | }) 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/config/migrations/3.5.0.ts: -------------------------------------------------------------------------------- 1 | import { storageHelper } from 'utils/storageHelper' 2 | import { Migration, onConfigOutdated } from '.' 3 | 4 | export const migration: Migration = { 5 | version: '3.5.0', 6 | async migrate(version) { 7 | // disable copy snippet button for github.com 8 | type ConfigBeforeMigrate = { 9 | copyFileButton: boolean 10 | } 11 | type ConfigAfterMigrate = { 12 | copyFileButton: false 13 | } 14 | 15 | await onConfigOutdated(version, async configs => { 16 | const key = 'platform_github.com' 17 | const config = configs[key] 18 | if (typeof config === 'object' && config !== null && 'copyFileButton' in config) { 19 | const configBeforeMigrate = config as ConfigBeforeMigrate 20 | const { copyFileButton, ...rest } = configBeforeMigrate 21 | if (copyFileButton) { 22 | const configAfterMigrate: ConfigAfterMigrate = { 23 | ...rest, 24 | copyFileButton: false, 25 | } 26 | await storageHelper.set({ 27 | [key]: configAfterMigrate, 28 | }) 29 | } 30 | } 31 | }) 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/config/migrations/clearRaiseErrorCache.ts: -------------------------------------------------------------------------------- 1 | import { storageHelper } from 'utils/storageHelper' 2 | import { Migration, onConfigOutdated } from '.' 3 | import packageJson from '../../../../package.json' 4 | 5 | // Run every time a new version is released. 6 | export const migration: Migration = { 7 | version: packageJson.version, 8 | async migrate(version) { 9 | await onConfigOutdated(version, async () => { 10 | await storageHelper.set({ raiseErrorCache: [] }) 11 | }) 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/config/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import { storageHelper, storageKeys } from 'utils/storageHelper' 2 | import packageJson from '../../../../package.json' 3 | import { Storage } from '../../storageHelper' 4 | import { migration as v1v0v1 } from './1.0.1' 5 | import { migration as v1v3v4 } from './1.3.4' 6 | import { migration as v2v6v0 } from './2.6.0' 7 | import { migration as v3v0v0 } from './3.0.0' 8 | import { migration as v3v5v0 } from './3.5.0' 9 | import { migration as clearRaiseErrorCache } from './clearRaiseErrorCache' 10 | 11 | export type Migration = { 12 | version: string 13 | migrate(version: Migration['version']): Async 14 | } 15 | 16 | export async function migrateConfig() { 17 | const migrations: Migration[] = [v1v0v1, v1v3v4, v2v6v0, v3v0v0, v3v5v0] 18 | migrations.push(clearRaiseErrorCache) // Make sure this is run after other version-specific migrations 19 | 20 | for (const { version, migrate } of migrations) { 21 | await migrate(version) 22 | } 23 | 24 | await storageHelper.set({ [storageKeys.configVersion]: packageJson.version }) 25 | } 26 | 27 | export async function onConfigOutdated( 28 | migrationConfigVersion: string, 29 | runIfOutdated: (config: T) => Async, 30 | ) { 31 | const config = await storageHelper.get() 32 | 33 | if (config) { 34 | const { 35 | [storageKeys.configVersion]: savedConfigVersion, 36 | [storageKeys.raiseErrorCache]: __, // eslint-disable-line @typescript-eslint/no-unused-vars 37 | ...restConfig 38 | } = config 39 | if (savedConfigVersion < migrationConfigVersion) { 40 | await runIfOutdated(restConfig as T) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/createAnchorClickHandler.ts: -------------------------------------------------------------------------------- 1 | import { isOpenInNewWindowClick } from './general' 2 | import { loadWithFastRedirect } from './hooks/useFastRedirect' 3 | 4 | export function createAnchorClickHandler(url: string) { 5 | return (e: React.MouseEvent) => { 6 | if (isOpenInNewWindowClick(e)) return 7 | 8 | e.preventDefault() 9 | loadWithFastRedirect(url, e.currentTarget) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/cx.ts: -------------------------------------------------------------------------------- 1 | type CXValue = string | boolean | null | undefined 2 | 3 | /** 4 | * cx('class1', { class2: true, class3: false }) --> 'class1 class2' 5 | */ 6 | export function cx( 7 | ...classNames: ( 8 | | CXValue 9 | | { 10 | [className: string]: CXValue 11 | } 12 | )[] 13 | ): string { 14 | return classNames.map(handleClassName).filter(Boolean).join(' ') 15 | } 16 | 17 | function handleClassName(className: Parameters[number]): string | false { 18 | switch (typeof className) { 19 | case 'string': 20 | return className 21 | case 'object': 22 | return className === null 23 | ? false 24 | : cx(...Object.entries(className).map(([key, value]) => (value ? key : false))) 25 | default: 26 | return false 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/features.ts: -------------------------------------------------------------------------------- 1 | export const resize = Boolean(window.ResizeObserver) 2 | -------------------------------------------------------------------------------- /src/utils/general.test.ts: -------------------------------------------------------------------------------- 1 | import { atomicAsyncFunction, resolveDiffGraphMeta } from './general' 2 | 3 | it(`should resolve diff stat graph meta properly`, () => { 4 | const example = ` 5 | 2 10 0 4 6 | 3 10 1 3 7 | 3 17 0 4 8 | 4 17 0 4 9 | 4 26 0 4 10 | 5 17 1 3 11 | 6 17 1 3 12 | 12 0 5 0 13 | 0 12 0 5 14 | 17 74 0 4 15 | 11 23 1 3 16 | 18 28 1 3 17 | 34 24 2 2 18 | ` 19 | 20 | example 21 | .split(/\n/) 22 | .map(line => line.trim()) 23 | .filter(line => line.length) 24 | .map(line => line.split(/\s+/).map(_ => parseInt(_))) 25 | .forEach(([additions, deletions, g, r]) => { 26 | const meta = resolveDiffGraphMeta(additions, deletions, additions + deletions) 27 | expect([meta.g, meta.r]).toEqual([g, r]) 28 | }) 29 | }) 30 | 31 | it(`should schedule atomic promises properly`, async () => { 32 | const sleep = (duration: number) => new Promise(resolve => setTimeout(resolve, duration)) 33 | 34 | const recorder: string[] = [] 35 | const sleepWithNoise = async (duration: number, noise: string) => { 36 | await sleep(duration) 37 | recorder.push(noise) 38 | return noise 39 | } 40 | 41 | const atomicSleep = atomicAsyncFunction(sleepWithNoise) 42 | 43 | recorder.length = 0 44 | const atomicReturns = await Promise.all([atomicSleep(200, 'a'), atomicSleep(100, 'b')]) 45 | // Expected time sheet 46 | // 0 100 200 300 47 | // [a ] 48 | // [b ] 49 | // Recorder: [a, b] 50 | // 51 | expect(recorder).toEqual(['a', 'b']) 52 | expect(atomicReturns).toEqual(['a', 'b']) 53 | 54 | recorder.length = 0 55 | const normalReturns = await Promise.all([sleepWithNoise(200, 'a'), sleepWithNoise(100, 'b')]) 56 | // Time sheet if not atomic 57 | // 0 100 200 58 | // [a ] 59 | // [b ] 60 | // Recorder: [b, a] 61 | expect(recorder).toEqual(['b', 'a']) 62 | expect(normalReturns).toEqual(['a', 'b']) 63 | }) 64 | -------------------------------------------------------------------------------- /src/utils/getSafeWidth.test.ts: -------------------------------------------------------------------------------- 1 | import { getSafeWidth, MINIMAL_CONTENT_VIEWPORT_WIDTH, MINIMAL_WIDTH } from './getSafeWidth' 2 | 3 | jest.retryTimes(3) // Math.random may result in failure due to floating point precision 4 | 5 | it(`should shrink when window is being resized smaller`, () => { 6 | const randomGrow = 100 * Math.random() 7 | expect( 8 | getSafeWidth( 9 | MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH + randomGrow * 2, 10 | MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH + randomGrow, 11 | ), 12 | ).toBe(MINIMAL_WIDTH + randomGrow) 13 | }) 14 | 15 | it(`should not shrink when window is being resized smaller than minimal size`, () => { 16 | const randomGrow = 100 * Math.random() 17 | expect(getSafeWidth(0, MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH - randomGrow)).toBe( 18 | MINIMAL_WIDTH, 19 | ) 20 | }) 21 | 22 | it(`should return user-preferred size if not reaching bounds`, () => { 23 | const randomGrow = 100 * Math.random() 24 | expect( 25 | getSafeWidth( 26 | MINIMAL_WIDTH + randomGrow, 27 | MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH + randomGrow * 2, 28 | ), 29 | ).toBe(MINIMAL_WIDTH + randomGrow) 30 | }) 31 | -------------------------------------------------------------------------------- /src/utils/getSafeWidth.ts: -------------------------------------------------------------------------------- 1 | import { Size } from '../components/Size' 2 | 3 | export const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100 4 | export const MINIMAL_WIDTH = 240 5 | 6 | export function getSafeWidth(width: Size, windowWidth: number) { 7 | // if window width is too small, prevent reducing anymore 8 | if (windowWidth < MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH) return MINIMAL_WIDTH 9 | // if trying to enlarge to much, leave some space 10 | if (width > windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH) 11 | return windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH 12 | if (width < MINIMAL_WIDTH) return MINIMAL_WIDTH 13 | return width 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/gitSubmodule.ts: -------------------------------------------------------------------------------- 1 | import * as ini from 'ini' 2 | import { findNode } from 'utils/general' 3 | 4 | const subModuleURLRegex = { 5 | HTTP: /^https?:\/\/.*?$/, 6 | HTTPGit: /^https:.*?\.git$/, 7 | git: /^git@.*?:(.*?)\/(.*?)\.git$/, 8 | } 9 | 10 | function transformModuleGitURL(node: TreeNode, URL: string) { 11 | const matched = URL.match(subModuleURLRegex.git) 12 | if (!matched) return 13 | const [, userName, repoName] = matched 14 | return appendCommitPath(`${window.location.origin}/${userName}/${repoName}`, node) 15 | } 16 | 17 | function cutDotGit(URL: string) { 18 | return URL.replace(/\.git$/, '') 19 | } 20 | 21 | function appendCommitPath(URL: string, node: TreeNode) { 22 | return URL.replace(/\/?$/, `/tree/${node.sha}`) 23 | } 24 | 25 | function transformModuleHTTPDotGitURL(node: TreeNode, URL: string) { 26 | return appendCommitPath(cutDotGit(URL), node) 27 | } 28 | 29 | function transformModuleHTTPURL(node: TreeNode, URL: string) { 30 | return appendCommitPath(URL, node) 31 | } 32 | 33 | type ParsedINI = { 34 | [key: string]: ParsedModule | ParsedINI | undefined 35 | } 36 | 37 | type ParsedModule = { 38 | [key: string]: string | undefined 39 | } 40 | 41 | // TODO: merge into getTreeData callback 42 | async function handleParsed(root: TreeNode, parsed: ParsedINI) { 43 | for (const value of Object.values(parsed)) { 44 | if (typeof value === 'string') return 45 | const url = value?.url 46 | const path = value?.path 47 | if (typeof url === 'string' && typeof path === 'string') { 48 | const node = await findNode(root, path) 49 | if (node) { 50 | if (subModuleURLRegex.HTTPGit.test(url)) { 51 | node.url = transformModuleHTTPDotGitURL(node, url) 52 | } else if (subModuleURLRegex.git.test(url)) { 53 | node.url = transformModuleGitURL(node, url) 54 | } else if (subModuleURLRegex.HTTP.test(url)) { 55 | node.url = transformModuleHTTPURL(node, url) 56 | } else { 57 | node.accessDenied = true 58 | } 59 | } else { 60 | // It turns out that we did not miss any submodule after a lot of tests. 61 | // Commenting this. 62 | // raiseError(new Error(`Submodule node not found`), { path }) 63 | } 64 | } else { 65 | await handleParsed(root, value as ParsedINI) 66 | } 67 | } 68 | } 69 | 70 | export async function resolveGitModules(root: TreeNode, content: string) { 71 | try { 72 | if (Array.isArray(root.contents)) { 73 | const parsed: ParsedINI = ini.parse(content) 74 | await handleParsed(root, parsed) 75 | } 76 | } catch (err) { 77 | throw new Error(`Error resolving git modules`) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/utils/hooks/useAbortableEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | /** 4 | * This effect addresses such a problem: 5 | * the later effect ends earlier than the previous one, and the previous effect overlaps later effect's result. 6 | */ 7 | export function useAbortableEffect( 8 | effect: () => { 9 | getAsyncGenerator: () => AsyncGenerator 10 | cancel?: () => void 11 | }, 12 | ) { 13 | useEffect(() => { 14 | const abortController = new AbortController() 15 | // The previous effect should stop running if the signal indicates should abort 16 | const { getAsyncGenerator, cancel } = effect() 17 | runAbortableAsyncGenerator(getAsyncGenerator(), abortController.signal) 18 | return () => { 19 | cancel?.() 20 | abortController.abort() 21 | } 22 | }, [effect]) 23 | } 24 | 25 | async function runAbortableAsyncGenerator( 26 | generator: AsyncGenerator, 27 | signal?: AbortSignal, 28 | ) { 29 | let latestResult: IteratorResult | undefined 30 | do { 31 | if (signal?.aborted) return 32 | latestResult = await generator.next(await latestResult?.value) 33 | } while (!latestResult.done) 34 | return latestResult.value 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/hooks/useCSSVariable.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | import { setCSSVariable } from 'utils/DOMHelper' 3 | 4 | export function useCSSVariable( 5 | name: string, 6 | value: string, 7 | element: HTMLElement = document.documentElement, 8 | ) { 9 | useLayoutEffect(() => { 10 | setCSSVariable(name, value, element) 11 | }, [name, value, element]) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/hooks/useConditionalHook.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | export function useConditionalHook(condition: () => boolean, hook: () => T) { 4 | const [use] = useState(condition) 5 | if (use) return hook() 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/hooks/useEffectOnSerializableUpdates.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from 'react' 2 | 3 | export function useEffectOnSerializableUpdates( 4 | value: T, 5 | serialize: (value: T) => string, 6 | onChange: (value: T) => void, 7 | ) { 8 | const serialized = useMemo(() => serialize(value), [value, serialize]) 9 | useEffect(() => onChange(value), [onChange, serialized]) // eslint-disable-line react-hooks/exhaustive-deps 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/hooks/useElementSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import * as features from 'utils/features' 3 | import { Size2D } from '../../components/Size' 4 | 5 | export function useElementSize() { 6 | const ref = useRef(null) 7 | 8 | const [size, setSize] = useState([0, 0]) 9 | 10 | useEffect(() => { 11 | if (ref.current) { 12 | if (features.resize) { 13 | const observer = new window.ResizeObserver(entries => { 14 | const entry = entries[0] 15 | if (!entry) return 16 | const { width, height } = entry.contentRect 17 | setSize([width, height]) 18 | }) 19 | observer.observe(ref.current) 20 | return () => observer.disconnect() 21 | } else if ('getBoundingClientRect' in ref.current) { 22 | const { width, height } = ref.current.getBoundingClientRect() 23 | setSize([width, height]) 24 | } 25 | } 26 | }, []) 27 | 28 | return { 29 | ref, 30 | size, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/hooks/useFastRedirect.ts: -------------------------------------------------------------------------------- 1 | import { useConfigs } from 'containers/ConfigsContext' 2 | import { platform } from 'platforms' 3 | import { useCallback, useEffect, useRef } from 'react' 4 | import { useEvent, useInterval } from 'react-use' 5 | import { run } from 'utils/general' 6 | 7 | const config: import('pjax-api').Config = { 8 | areas: [ 9 | // github 10 | '.repository-content', 11 | // gitee 12 | '#git-project-content', 13 | // gitea 14 | '.repository > .ui.container', 15 | ], 16 | update: { 17 | css: false, 18 | }, 19 | fetch: {}, 20 | link: 'a:not(a)', // this helps fixing the go-back-in-history issue 21 | form: 'form:not(form)', // prevent blocking form submissions 22 | fallback(/* target, reason */) { 23 | // prevent unexpected reload 24 | }, 25 | } 26 | 27 | export function usePJAXAPI() { 28 | const { pjaxMode } = useConfigs().value 29 | // make history travel work 30 | useEffect(() => { 31 | if (pjaxMode === 'pjax-api') { 32 | run(async () => { 33 | const { Pjax } = await import('pjax-api') 34 | new Pjax({ 35 | ...config, 36 | filter() { 37 | return false 38 | }, 39 | }) 40 | }) 41 | } 42 | }, []) // eslint-disable-line react-hooks/exhaustive-deps 43 | 44 | // bindings for legacy support 45 | useRedirectedEvents(window, 'pjax:fetch', 'pjax:start', document) 46 | useRedirectedEvents(document, 'pjax:ready', 'pjax:end') 47 | } 48 | 49 | export const loadWithFastRedirect = (url: string, element: HTMLElement) => { 50 | // eslint-disable-next-line @typescript-eslint/no-var-requires 51 | platform.loadWithFastRedirect?.(url, element) || require('pjax-api').Pjax.assign(url, config) 52 | } 53 | 54 | export function useAfterRedirect(callback: () => void) { 55 | const latestHref = useRef(location.href) 56 | const raceCallback = useCallback(() => { 57 | const { href } = location 58 | if (latestHref.current !== href) { 59 | latestHref.current = href 60 | callback() 61 | } 62 | }, [callback]) 63 | useInterval(raceCallback, 500) 64 | useEvent('pjax:end', raceCallback, document) // legacy support 65 | useEvent('turbo:render', raceCallback, document) // prevent page content shift after first redirect to new page via turbo when sidebar is pinned 66 | } 67 | 68 | export function useRedirectedEvents( 69 | originalTarget: Window | Document | Element, 70 | originalEvent: string, 71 | redirectedEvent: string, 72 | redirectToTarget = originalTarget, 73 | ) { 74 | useEvent( 75 | originalEvent, 76 | () => { 77 | redirectToTarget.dispatchEvent(new Event(redirectedEvent)) 78 | }, 79 | originalTarget, 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/hooks/useHandleNetworkError.ts: -------------------------------------------------------------------------------- 1 | import { useConfigs } from 'containers/ConfigsContext' 2 | import { errors, platform, platformName } from 'platforms' 3 | import { useCallback } from 'react' 4 | import { useLoadedContext } from 'utils/hooks/useLoadedContext' 5 | import { SideBarErrorContext } from '../../containers/ErrorContext' 6 | import { SideBarStateContext } from '../../containers/SideBarState' 7 | 8 | export function useHandleNetworkError() { 9 | const { accessToken } = useConfigs().value 10 | const changeErrorContext = useLoadedContext(SideBarErrorContext).onChange 11 | const changeStateContext = useLoadedContext(SideBarStateContext).onChange 12 | 13 | return useCallback( 14 | function handleNetworkError(err: Error) { 15 | const message = platform.mapErrorMessage?.(err) || err.message 16 | if (message === errors.EMPTY_PROJECT) { 17 | changeErrorContext('This project seems to be empty.') 18 | return 19 | } 20 | 21 | if (message === errors.BLOCKED_PROJECT) { 22 | changeErrorContext('Access to the project is blocked.') 23 | return 24 | } 25 | 26 | if ( 27 | message === errors.NOT_FOUND || 28 | message === errors.BAD_CREDENTIALS || 29 | message === errors.API_RATE_LIMIT 30 | ) { 31 | changeStateContext('error-due-to-auth') 32 | return 33 | } 34 | 35 | if (message === errors.CONNECTION_BLOCKED) { 36 | if (accessToken) changeErrorContext(`Cannot connect to ${platformName}.`) 37 | else changeStateContext('error-due-to-auth') 38 | 39 | return 40 | } 41 | 42 | if (message === errors.SERVER_FAULT) { 43 | changeErrorContext(`${platformName} server went down.`) 44 | return 45 | } 46 | 47 | changeStateContext('disabled') 48 | changeErrorContext('Something unexpected happened.') 49 | throw err 50 | }, 51 | [accessToken, changeErrorContext, changeStateContext], 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/hooks/useLoadedContext.ts: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | 3 | export function useLoadedContext(context: React.Context): T { 4 | const ctx = useContext(context) 5 | if (ctx === null) throw new Error(`Context not loaded`) 6 | return ctx 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/hooks/useOnLocationChange.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useLocation } from 'react-use' 3 | 4 | export function useOnLocationChange( 5 | callback: React.EffectCallback, 6 | extraDeps: React.DependencyList = [], 7 | ) { 8 | const { href, pathname, search } = useLocation() 9 | useEffect(callback, [href, pathname, search, callback, ...extraDeps]) 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/hooks/useOnShortcutPressed.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useLoadedContext } from 'utils/hooks/useLoadedContext' 3 | import * as keyHelper from 'utils/keyHelper' 4 | import { SideBarStateContext } from '../../containers/SideBarState' 5 | 6 | export function useOnShortcutPressed( 7 | shortcut: string | undefined, 8 | onPressed: (e: KeyboardEvent) => void, 9 | ) { 10 | const state = useLoadedContext(SideBarStateContext).value 11 | const isDisabled = state === 'disabled' || !shortcut 12 | useEffect( 13 | function attachKeyDown() { 14 | if (isDisabled) return 15 | 16 | function onKeyDown(e: KeyboardEvent) { 17 | const keys = keyHelper.parseEvent(e) 18 | if (keys === shortcut) onPressed(e) 19 | } 20 | window.addEventListener('keydown', onKeyDown) 21 | return () => window.removeEventListener('keydown', onKeyDown) 22 | }, 23 | [onPressed, isDisabled, shortcut], 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/hooks/useProgressBar.ts: -------------------------------------------------------------------------------- 1 | import * as NProgress from 'nprogress' 2 | import { useEffect } from 'react' 3 | import { useEvent } from 'react-use' 4 | 5 | const progressBar = { 6 | mount() { 7 | NProgress.start() 8 | }, 9 | unmount() { 10 | NProgress.done() 11 | }, 12 | } 13 | 14 | export function useProgressBar() { 15 | useEffect(() => { 16 | NProgress.configure({ showSpinner: false }) 17 | }, []) 18 | useEvent('pjax:fetch', progressBar.mount, window) 19 | useEvent('pjax:unload', progressBar.unmount, window) 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/hooks/useStateIO.ts: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from 'react' 2 | 3 | export function useStateIO(initialState: S | (() => S)): { 4 | value: S 5 | onChange: React.Dispatch> 6 | } { 7 | const [value, onChange] = useState(initialState) 8 | return useMemo(() => ({ value, onChange }), [value]) 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/hooks/useUpdateReason.ts: -------------------------------------------------------------------------------- 1 | import { IN_PRODUCTION_MODE } from 'env' 2 | import { useEffect, useRef } from 'react' 3 | 4 | export function useUpdateReason

>(props: P) { 5 | const lastPropsRef = useRef

(props) 6 | useEffect(() => { 7 | if (IN_PRODUCTION_MODE) return 8 | const output: ([string, keyof P, P[keyof P]] | [string, keyof P, P[keyof P], P[keyof P]])[] = [] 9 | for (const key of Object.keys(props)) { 10 | if (key === 'children') continue 11 | const $key = key as keyof P 12 | if (!(key in lastPropsRef.current)) output.push([`[Added]`, $key, props[$key]]) 13 | if (lastPropsRef.current[$key] !== props[$key]) 14 | output.push([`[Updated]`, $key, lastPropsRef.current[$key], props[$key]]) 15 | } 16 | 17 | for (const key of Object.keys(lastPropsRef.current)) { 18 | if (key === 'children') continue 19 | const $key = key as keyof P 20 | if (!(key in props)) output.push([`[Removed]`, $key, props[$key]]) 21 | } 22 | 23 | if (output.length) { 24 | console.group(`[Update Reasons]`) 25 | for (const record of output) { 26 | console.log(...record.map(r => (typeof r === 'function' ? '[fn]' : r))) 27 | } 28 | console.groupEnd() 29 | } 30 | 31 | lastPropsRef.current = props 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/keyHelper.ts: -------------------------------------------------------------------------------- 1 | const keyCodeArray = [ 2 | ...'1234567890abcdefghijklmnopqrstuvwxyz'.split(''), 3 | /* the key that's located left to the 1 key in Mac 4 | keybords in multiple Engligh laybouts (Backquote) */ 5 | ...'`§'.split(''), 6 | ..."[]\\;',./".split(''), 7 | 'alt', 8 | 'shift', 9 | 'ctrl', 10 | 'meta', 11 | ] 12 | const validKeyCodes = new Set(keyCodeArray) 13 | 14 | function isValidKey(key: string) { 15 | return validKeyCodes.has(key) 16 | } 17 | 18 | /** 19 | * parse a string representation of key combination 20 | * 21 | * @param {string} keysString 22 | * @returns {string} 23 | */ 24 | function parse(keysString: string) { 25 | return ( 26 | keysString 27 | .split('+') 28 | /* when trying to set a combination includes '+', 29 | input should be 'shift + =' instead of 'shift + +', 30 | thus a valid key string won't contain '++' */ 31 | .map(_ => _.trim().toLowerCase()) 32 | .filter(isValidKey) 33 | .sort((a, b) => keyCodeArray.indexOf(b) - keyCodeArray.indexOf(a)) 34 | .join('+') 35 | ) 36 | } 37 | 38 | function parseKeyCode(code: string) { 39 | return code.toLowerCase().replace(/^control$/, 'ctrl') 40 | } 41 | 42 | export function parseEvent(e: KeyboardEvent | React.KeyboardEvent) { 43 | const { altKey: alt, shiftKey: shift, metaKey: meta, ctrlKey: ctrl } = e 44 | try { 45 | const code = parseKeyCode(e.key) 46 | const keys = { meta, ctrl, shift, alt, [code]: true } 47 | const combination = parse( 48 | Object.entries(keys) 49 | .filter(([, pressed]) => pressed) 50 | .map(([key]) => key) 51 | .join('+'), 52 | ) 53 | return combination 54 | } catch (err) { 55 | const serializedKeyData = JSON.stringify({ 56 | keyCode: e.keyCode, 57 | key: e.key, 58 | charCode: e.charCode, 59 | }) 60 | throw new Error(`Error parse keyboard event: ${serializedKeyData}`) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/networkService.ts: -------------------------------------------------------------------------------- 1 | export const gitakoServiceHost = 'gitako.enix.one' 2 | -------------------------------------------------------------------------------- /src/utils/parseIconMapCSV.ts: -------------------------------------------------------------------------------- 1 | import rawFileIconIndex from 'assets/icons/file-icons-index.csv' 2 | import rawFolderIconIndex from 'assets/icons/folder-icons-index.csv' 3 | 4 | const rowSeparator = '\n' 5 | const colSeparator = ',' 6 | const arraySeparator = ':' 7 | 8 | function parseFileIconMapCSV() { 9 | const filenameIndex = new Map() 10 | const fileExtensionIndex = new Map() 11 | for (const line of rawFileIconIndex.split(rowSeparator)) { 12 | if (!line) continue 13 | const [name, names, exts] = line.split(colSeparator) 14 | if (names) { 15 | for (const filename of names.split(arraySeparator)) { 16 | if (!filename) continue 17 | filenameIndex.set(filename, name) 18 | } 19 | } 20 | if (exts) { 21 | for (const ext of exts.split(arraySeparator)) { 22 | if (!ext) continue 23 | fileExtensionIndex.set(ext, name) 24 | } 25 | } 26 | } 27 | return { 28 | filenameIndex, 29 | fileExtensionIndex, 30 | } 31 | } 32 | 33 | function parseFolderIconMapCSV() { 34 | const folderNameIndex = new Map() 35 | for (const line of rawFolderIconIndex.split(rowSeparator)) { 36 | if (!line) continue 37 | const [name, names] = line.split(colSeparator) 38 | for (const folderName of names.split(arraySeparator)) { 39 | if (!folderName) continue 40 | folderNameIndex.set(folderName, name) 41 | } 42 | } 43 | return { 44 | folderNameIndex, 45 | } 46 | } 47 | 48 | const { folderNameIndex } = parseFolderIconMapCSV() 49 | 50 | export function getFolderIconURL(node: TreeNode, open: boolean) { 51 | const name = folderNameIndex.get(node.name.toLowerCase()) 52 | return getIconURL('folder', name, open) 53 | } 54 | 55 | const { filenameIndex, fileExtensionIndex } = parseFileIconMapCSV() 56 | 57 | export function getFileIconURL(node: TreeNode) { 58 | const fileName = node.name.toLowerCase() 59 | let iconName = filenameIndex.get(fileName) 60 | if (!iconName) { 61 | const tail = fileName.split('.') 62 | while (!iconName && tail.length > 0) { 63 | iconName = fileExtensionIndex.get(tail.join('.')) 64 | tail.shift() 65 | } 66 | } 67 | return getIconURL('file', iconName) 68 | } 69 | 70 | // memorize for 71 | // 1. swap time with space 72 | // 2. prevent app crash on when extension context invalidates 73 | const extensionURL = browser.runtime.getURL('').replace(/\/$/, '') 74 | export function getIconURL(type: 'folder' | 'file', name = 'default', open?: boolean) { 75 | const filename = 76 | (name === 'default' ? 'default_' + type : type + '_type_' + name) + 77 | (open ? '_opened' : '') + 78 | '.svg' 79 | return extensionURL + `/icons/vscode/${filename}` 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/storageHelper.ts: -------------------------------------------------------------------------------- 1 | const localStorage = browser.storage.local 2 | 3 | const keys = { 4 | configVersion: 'configVersion', 5 | raiseErrorCache: 'raiseErrorCache', 6 | } as const 7 | 8 | export const storageKeys = keys 9 | 10 | export type Storage = { 11 | // save root level keys for easier future migrating 12 | [key in EnumString]: string 13 | 14 | // separate different platform configs to simplify interactions with browser storage API 15 | // e.g. 16 | // ['platform_github.com']?: Config 17 | } 18 | 19 | async function get(mapping: string | string[] | null = null) { 20 | return (await localStorage.get(mapping || undefined)) as T | undefined 21 | } 22 | 23 | function set>(value: T): Promise | void { 24 | return localStorage.set(value) 25 | } 26 | 27 | export const storageHelper = { get, set } 28 | -------------------------------------------------------------------------------- /src/utils/treeParser.ts: -------------------------------------------------------------------------------- 1 | const isFolder = (node: TreeNode) => node.type === 'tree' 2 | const isNotFolder = (node: TreeNode) => node.type !== 'tree' 3 | 4 | export function sortFoldersToFront(root: TreeNode) { 5 | const nodes = root.contents 6 | if (nodes) { 7 | nodes.splice(0, Infinity, ...nodes.filter(isFolder), ...nodes.filter(isNotFolder)) 8 | nodes.forEach(sortFoldersToFront) 9 | } 10 | } 11 | 12 | export function findGitModules(root: TreeNode) { 13 | if (root.contents) { 14 | const modulesFile = root.contents.find(content => content.name === '.gitmodules') 15 | if (modulesFile) { 16 | return modulesFile 17 | } 18 | } 19 | return null 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/waitForNextEvent.ts: -------------------------------------------------------------------------------- 1 | export const waitForNextDocumentEvent = ( 2 | type: EnumString, 3 | options?: boolean | AddEventListenerOptions, 4 | ) => 5 | new Promise(resolve => { 6 | const listener = (ev: DocumentEventMap[K] | Event) => { 7 | document.removeEventListener(type, listener, options) 8 | resolve(ev) 9 | } 10 | document.addEventListener(type, listener, options) 11 | }) 12 | 13 | export const waitForNextWindowEvent = ( 14 | type: EnumString, 15 | options?: boolean | AddEventListenerOptions, 16 | ) => 17 | new Promise(resolve => { 18 | const listener = (ev: WindowEventMap[K] | Event) => { 19 | window.removeEventListener(type, listener, options) 20 | resolve(ev) 21 | } 22 | window.addEventListener(type, listener, options) 23 | }) 24 | 25 | export const waitForNext = { 26 | documentEvent: waitForNextDocumentEvent, 27 | windowEvent: waitForNextWindowEvent, 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "files": true, 4 | "compilerOptions": { 5 | "module": "CommonJS" 6 | } 7 | }, 8 | "compilerOptions": { 9 | "moduleResolution": "Node", 10 | "module": "ESNext", 11 | "target": "ESNext", 12 | "jsx": "react", 13 | "strict": true, 14 | "lib": ["dom", "es2017.object", "es2016", "ES2019.Array", "ES2020.String", "ES2022.Error"], 15 | "baseUrl": "src", 16 | "resolveJsonModule": true, 17 | "allowSyntheticDefaultImports": true, 18 | "outDir": "dist" 19 | }, 20 | "include": ["src"] 21 | } 22 | --------------------------------------------------------------------------------