├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .env.example ├── .github └── workflows │ ├── build.yml │ ├── pr-closed.yml │ ├── pr-title.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .vscode └── extensions.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── PromoTile-1400x560.png ├── PromoTile-440x280.png ├── PromoTile-920x680.png ├── chrome-web-store.png ├── example-dark.png ├── example-light.png ├── firefox-addons.png ├── logo.png ├── screenshot-dark.png └── screenshot-light.png ├── biome.jsonc ├── commitlint.config.js ├── logo.png ├── logo.svg ├── package-lock.json ├── package.json ├── renovate.json ├── scripts ├── build-icons.ts ├── build-languages.ts ├── build-src.ts ├── update-manifest-version.ts └── update-upstream-version.ts ├── src ├── custom │ └── folder-symlink.svg ├── extensionIcons │ ├── icon-128.png │ ├── icon-16.png │ ├── icon-32.png │ └── icon-48.png ├── injected-styles.css ├── lib │ ├── custom-providers.ts │ ├── icon-sizes.ts │ ├── replace-icon.ts │ ├── replace-icons.ts │ └── user-config.ts ├── logo.svg ├── main.ts ├── manifests │ ├── base.json │ ├── chrome-edge.json │ └── firefox.json ├── models │ ├── index.ts │ └── provider.ts ├── providers │ ├── azure.ts │ ├── bitbucket.ts │ ├── gitea.ts │ ├── gitee.ts │ ├── github.ts │ ├── gitlab.ts │ ├── index.ts │ └── sourceforge.ts └── ui │ ├── options │ ├── api │ │ ├── domains.ts │ │ ├── icons.ts │ │ └── language-ids.ts │ ├── components │ │ ├── confirm-dialog.tsx │ │ ├── domain-actions.tsx │ │ ├── domain-name.tsx │ │ ├── domain-settings.tsx │ │ ├── icon-settings │ │ │ ├── binding-input-controls.tsx │ │ │ ├── file-icon-bindings.tsx │ │ │ ├── folder-icon-bindings.tsx │ │ │ ├── icon-binding-controls.tsx │ │ │ ├── icon-preview.tsx │ │ │ ├── icon-settings-dialog.tsx │ │ │ └── language-icon-bindings.tsx │ │ └── main.tsx │ ├── options.css │ ├── options.html │ ├── options.tsx │ └── types │ │ └── binding-control-props.ts │ ├── popup │ ├── api │ │ ├── access.ts │ │ ├── helper.ts │ │ ├── page-state.ts │ │ └── provider.ts │ ├── components │ │ ├── add-provider.tsx │ │ ├── ask-for-access.tsx │ │ ├── domain-settings.tsx │ │ ├── loading-spinner.tsx │ │ ├── main.tsx │ │ └── not-supported.tsx │ ├── settings-popup.css │ ├── settings-popup.html │ └── settings-popup.tsx │ └── shared │ ├── domain-settings-controls.tsx │ ├── footer.tsx │ ├── info-popover.tsx │ ├── logo.tsx │ ├── theme.ts │ └── utils.ts └── tsconfig.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.{txt,md}] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Github token is used for scripts/build-languages.ts 2 | GITHUB_TOKEN= 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 🛠️ Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | runs-on: [ubuntu-latest] 17 | name: Build Material Icons Browser Extension 18 | steps: 19 | - name: 🛎️ Checkout 20 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 21 | with: 22 | persist-credentials: false 23 | 24 | - name: 📦 Install dependencies 25 | run: npm ci 26 | 27 | - name: 🧹 Check code quality 28 | run: npm run lint 29 | 30 | - name: 🛠️ Build extension 31 | run: npm run build 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/pr-closed.yml: -------------------------------------------------------------------------------- 1 | name: 🎉 PR closed 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - closed 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: write 11 | 12 | jobs: 13 | thank-you: 14 | runs-on: ubuntu-latest 15 | if: github.event.pull_request.merged == true 16 | 17 | steps: 18 | - name: 🙏 Post Thank You Comment 19 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 20 | with: 21 | script: | 22 | github.rest.issues.createComment({ 23 | issue_number: context.issue.number, 24 | owner: context.repo.owner, 25 | repo: context.repo.repo, 26 | body: ` 27 | ## Merge Successful 28 | 29 | Thanks for your contribution! 🎉 30 | 31 | The changes will be part of the upcoming update on the Marketplace.` 32 | }) 33 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: ✅ Check PR Title 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, synchronize] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | lint-pr-title: 12 | name: Check PR Title 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: 📥 Checkout 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | with: 19 | # Only fetch the config file from the repository 20 | sparse-checkout-cone-mode: false 21 | sparse-checkout: | 22 | commitlint.config.js 23 | 24 | - name: 📦 Install dependencies 25 | run: npm install --global @commitlint/config-conventional commitlint 26 | 27 | - name: 🔍 Check PR title with commitlint 28 | id: title-check 29 | env: 30 | PR_TITLE: ${{ github.event.pull_request.title }} 31 | run: echo "$PR_TITLE" | npx commitlint 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Release new version 2 | 3 | on: 4 | schedule: 5 | - cron: "0 3 * * *" # Every day at 3:00 UTC 6 | workflow_dispatch: 7 | 8 | jobs: 9 | preparation: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: read 14 | actions: read 15 | 16 | outputs: 17 | status: ${{ steps.check.outputs.status }} 18 | 19 | steps: 20 | - name: 📥 Checkout 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | persist-credentials: false 25 | 26 | - name: 📦 Install dependencies 27 | run: npm ci 28 | 29 | - name: 🔍 Fetch release version 30 | run: | 31 | release_version=$(npm view material-icon-theme version) 32 | current_version=$(npm list material-icon-theme --depth=0 | grep 'material-icon-theme@' | cut -d '@' -f 2) 33 | echo "release_version=$release_version" >> $GITHUB_ENV 34 | echo "current_version=$current_version" >> $GITHUB_ENV 35 | 36 | - name: ❌ Check release conditions 37 | id: check 38 | run: | 39 | status="skip" 40 | if [ "$release_version" != "$current_version" ] || [ "$GITHUB_EVENT_NAME" != "schedule" ]; then 41 | status="release" 42 | fi 43 | echo "status=$status" >> $GITHUB_OUTPUT 44 | 45 | release: 46 | runs-on: ubuntu-latest 47 | needs: preparation 48 | if: needs.preparation.outputs.status == 'release' 49 | 50 | permissions: 51 | contents: write 52 | id-token: write 53 | actions: write 54 | 55 | steps: 56 | - name: 🤖 Use App Token for the Bot which is allowed to create releases 57 | uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 58 | id: app-token 59 | with: 60 | app-id: ${{ vars.BOT_APP_ID }} 61 | private-key: ${{ secrets.BOT_PRIVATE_KEY }} 62 | 63 | - name: 📥 Checkout 64 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 65 | with: 66 | fetch-depth: 0 67 | token: ${{ steps.app-token.outputs.token }} 68 | 69 | - name: 🔧 Configure Git 70 | run: | 71 | git config user.name 'github-actions[bot]' 72 | git config user.email 'github-actions[bot]@users.noreply.github.com' 73 | git config --global push.followTags true 74 | 75 | - name: 📦 Install dependencies 76 | run: npm ci 77 | 78 | - name: 🔄 Attempt update and prepare release 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | run: | 82 | npm install material-icon-theme@5.x 83 | npx changelogen --bump --hideAuthorEmail 84 | npm run update-versions 85 | 86 | - name: 📝 Get metadata 87 | run: | 88 | VERSION=$(jq -r '.version' package.json) 89 | echo "VERSION=$VERSION" >> $GITHUB_ENV 90 | 91 | - name: 🏗️ Build extension 92 | env: 93 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 94 | run: npm run build 95 | 96 | - name: 📜 Commit and Tag Release 97 | env: 98 | # Don't run husky on `git commit` 99 | HUSKY: 0 100 | run: | 101 | git add package.json package-lock.json src/manifests/base.json CHANGELOG.md README.md 102 | git commit -m "chore(release): v$VERSION" 103 | git tag "v$VERSION" 104 | git push origin --follow-tags 105 | npx changelogen github release --token ${{ secrets.GITHUB_TOKEN }} 106 | 107 | - name: 🌐 Upload to chrome store 108 | continue-on-error: true 109 | uses: trmcnvn/chrome-addon@7fc5a5ad3ff597dc64d6a13de7dcaa8515328be7 # v2 110 | with: 111 | extension: bggfcpfjbdkhfhfmkjpbhnkhnpjjeomc 112 | zip: github-material-icons-chrome-extension.zip 113 | client-id: ${{ secrets.CHROME_CLIENT_ID }} 114 | client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} 115 | refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} 116 | 117 | - name: 🌐 Upload to edge store 118 | continue-on-error: true 119 | uses: wdzeng/edge-addon@d4db1eea77297a24d799394dec87e8912e0902f9 # v2.1.0 120 | with: 121 | product-id: f95e9c6a-6470-45a1-ae09-821d3b916923 122 | zip-path: github-material-icons-edge-extension.zip 123 | client-id: ${{ secrets.EDGE_CLIENT_ID }} 124 | api-key: ${{ secrets.EDGE_API_KEY }} 125 | 126 | - name: 🌐 Upload to firefox store 127 | continue-on-error: true 128 | run: npx web-ext sign -s ./dist/firefox/ --channel=listed --api-key=${{ secrets.FIREFOX_API_JWT_ISSUER }} --api-secret=${{ secrets.FIREFOX_API_JWT_SECRET }} 129 | 130 | - name: ⬆️ Upload zip files to GitHub release 131 | run: | 132 | gh release upload v$VERSION github-material-icons-chrome-extension.zip github-material-icons-edge-extension.zip github-material-icons-firefox-extension.zip 133 | env: 134 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 135 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | out/ 3 | github-material-icons-chrome-extension.zip 4 | github-material-icons-firefox-extension.zip 5 | github-material-icons-edge-extension.zip 6 | src/icon-list.json 7 | src/language-map.json 8 | svg/ 9 | data/ 10 | 11 | # Directories/files that may appear in your environment 12 | node_modules 13 | *.log 14 | .idea 15 | .DS_Store 16 | .eslintcache 17 | Thumbs.db 18 | 19 | .env 20 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | engine-strict = true 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "jock.svg", "editorconfig.editorconfig"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.10.4 4 | 5 | [compare changes](https://github.com/material-extensions/material-icons-browser-extension/compare/v1.10.3...v1.10.4) 6 | 7 | ## v1.10.3 8 | 9 | [compare changes](https://github.com/material-extensions/material-icons-browser-extension/compare/v1.10.2...v1.10.3) 10 | 11 | ## v1.10.2 12 | 13 | [compare changes](https://github.com/material-extensions/material-icons-browser-extension/compare/v1.10.1...v1.10.2) 14 | 15 | ## v1.10.1 16 | 17 | [compare changes](https://github.com/material-extensions/material-icons-browser-extension/compare/v1.10.0...v1.10.1) 18 | 19 | ### 🩹 Fixes 20 | 21 | - **ci:** Use Microsoft Edge Add-ons API v1.1 ([ea8e5eb](https://github.com/material-extensions/material-icons-browser-extension/commit/ea8e5eb)) 22 | - **ci:** Pr title workflow should be executed on updates ([bb1c2c5](https://github.com/material-extensions/material-icons-browser-extension/commit/bb1c2c5)) 23 | 24 | ### ❤️ Contributors 25 | 26 | - Philipp Kief ([@PKief](https://github.com/PKief)) 27 | 28 | ## v1.10.0 29 | 30 | [compare changes](https://github.com/material-extensions/material-icons-browser-extension/compare/v1.9.0...v1.10.0) 31 | 32 | ### 🚀 Enhancements 33 | 34 | - Update release workflow to use a preparation step ([061515a](https://github.com/material-extensions/material-icons-browser-extension/commit/061515a)) 35 | - Update icons ([0e01e40](https://github.com/material-extensions/material-icons-browser-extension/commit/0e01e40)) 36 | - **ci:** Update release process ([ebc3fed](https://github.com/material-extensions/material-icons-browser-extension/commit/ebc3fed)) 37 | 38 | ### 🩹 Fixes 39 | 40 | - **ci:** Fetch whole history to generate proper changelog ([408a51c](https://github.com/material-extensions/material-icons-browser-extension/commit/408a51c)) 41 | - Update changelog ([ff555f5](https://github.com/material-extensions/material-icons-browser-extension/commit/ff555f5)) 42 | - Remove outdated badge from readme ([eccdc9f](https://github.com/material-extensions/material-icons-browser-extension/commit/eccdc9f)) 43 | - Update readme description ([cfa3e83](https://github.com/material-extensions/material-icons-browser-extension/commit/cfa3e83)) 44 | - Update package-lock.json ([faeefdd](https://github.com/material-extensions/material-icons-browser-extension/commit/faeefdd)) 45 | - Custom GitLab domain on Firefox ([#121](https://github.com/material-extensions/material-icons-browser-extension/pull/121)) 46 | - **ci:** Update release process ([b7c65e6](https://github.com/material-extensions/material-icons-browser-extension/commit/b7c65e6)) 47 | 48 | ### ❤️ Contributors 49 | 50 | - Philipp Kief ([@PKief](https://github.com/PKief)) 51 | 52 | ## v1.8.33...v1.9.0 53 | 54 | [compare changes](https://github.com/material-extensions/material-icons-browser-extension/compare/v1.8.33...v1.9.0) 55 | 56 | ### 🚀 Enhancements 57 | 58 | - Update release workflow ([adf9b9d](https://github.com/material-extensions/material-icons-browser-extension/commit/adf9b9d)) 59 | 60 | ### 🩹 Fixes 61 | 62 | - Icon sizes other than medium work randomly ([#119](https://github.com/material-extensions/material-icons-browser-extension/pull/119)) 63 | - Update selectors for Gitea provider ([#120](https://github.com/material-extensions/material-icons-browser-extension/pull/120)) 64 | - **ci:** Update pr title workflow ([8363871](https://github.com/material-extensions/material-icons-browser-extension/commit/8363871)) 65 | - **ci:** Update pr closed workflow ([a619540](https://github.com/material-extensions/material-icons-browser-extension/commit/a619540)) 66 | - Update URL in comment ([e19b65d](https://github.com/material-extensions/material-icons-browser-extension/commit/e19b65d)) 67 | 68 | ### 🏡 Chore 69 | 70 | - **workflows:** Use app token in workflows ([7037529](https://github.com/material-extensions/material-icons-browser-extension/commit/7037529)) 71 | - **workflows:** Update workflows ([4f829af](https://github.com/material-extensions/material-icons-browser-extension/commit/4f829af)) 72 | - Create devcontainer.json ([#110](https://github.com/material-extensions/material-icons-browser-extension/pull/110)) 73 | 74 | ### ❤️ Contributors 75 | 76 | - Philipp Kief ([@PKief](https://github.com/PKief)) 77 | - Black-backdoor ([@black-backdoor](https://github.com/black-backdoor)) 78 | - Pham Minh Triet ([@Nanome203](https://github.com/Nanome203)) 79 | - Hema203 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Claudio Santos and Richard Lam 4 | (MIT) Copyright (c) 2021 Philipp Kief (VSCode Material Icon Theme) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Material Icons for GitHub

2 | 3 |
4 | 5 | ![Dark GitHub example](/assets/example-dark.png) 6 | ![Light GitHub example](/assets/example-light.png) 7 | 8 |

9 | 10 | 11 |

12 | 13 | Install directly from the Chrome Web Store | Microsoft Edge Addons Store | Firefox Addons
14 | 15 | --- 16 | 17 | 18 | 19 | 20 | 21 | ### About 22 | 23 | Material Icons for GitHub is a browser Extension that enhances repositories file browsers when navigating github.com. Replace default file/folder icons with material design icons tailored to each file type, tool and purpose in the project. 24 | 25 | Based and dependent on the popular [Material Icon Theme](https://github.com/material-extensions/vscode-material-icon-theme) extension for Visual Studio Code. All icons and file assignments on this project are pulled directly from that project, so any praise or design issues should be raised on the original repository. 26 | 27 | ### Build locally 28 | 29 | ```shell 30 | npm run build 31 | ``` 32 | 33 | ### Development 34 | 35 | Build only files from `src` folder, without re-downloading dependencies from [Material Icon Theme](https://github.com/material-extensions/vscode-material-icon-theme) 36 | 37 | ```shell 38 | npm run build-src 39 | ``` 40 | 41 | Rebuild extension logos from `src/logo.svg`. Only needed when `src/logo.svg` is changed. 42 | 43 | ```shell 44 | npm run rebuild-logos 45 | ``` 46 | 47 | Zip `dist` folder for upload to Chrome Web Store and Firefox. _This script needs Zip to be available on PATH_ 48 | 49 | ```shell 50 | npm run bundle 51 | ``` 52 | 53 | Update language-map.json with latest language contributions. 54 | 55 | ```shell 56 | npm run build-languages 57 | ``` 58 | 59 | --- 60 | 61 | _Original extension developed with [Richard Lam](https://github.com/rlam108)_ 62 | -------------------------------------------------------------------------------- /assets/PromoTile-1400x560.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/PromoTile-1400x560.png -------------------------------------------------------------------------------- /assets/PromoTile-440x280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/PromoTile-440x280.png -------------------------------------------------------------------------------- /assets/PromoTile-920x680.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/PromoTile-920x680.png -------------------------------------------------------------------------------- /assets/chrome-web-store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/chrome-web-store.png -------------------------------------------------------------------------------- /assets/example-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/example-dark.png -------------------------------------------------------------------------------- /assets/example-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/example-light.png -------------------------------------------------------------------------------- /assets/firefox-addons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/firefox-addons.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/logo.png -------------------------------------------------------------------------------- /assets/screenshot-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/screenshot-dark.png -------------------------------------------------------------------------------- /assets/screenshot-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/assets/screenshot-light.png -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.2/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "formatWithErrors": false, 6 | "indentStyle": "space", 7 | "indentWidth": 2, 8 | "lineEnding": "lf", 9 | "lineWidth": 80, 10 | "attributePosition": "auto" 11 | }, 12 | "organizeImports": { "enabled": true }, 13 | "linter": { 14 | "enabled": true, 15 | "rules": { 16 | "recommended": false, 17 | "complexity": { "useArrowFunction": "off" }, 18 | "correctness": { 19 | "noUnsafeFinally": "error", 20 | "noUnusedVariables": "error" 21 | }, 22 | "security": { "noGlobalEval": "error" }, 23 | "style": { 24 | "noVar": "error", 25 | "useBlockStatements": "off", 26 | "useConst": "error", 27 | "useNamingConvention": { 28 | "level": "error", 29 | "options": { "strictCase": false } 30 | } 31 | }, 32 | "suspicious": { 33 | "noDoubleEquals": "error", 34 | "useNamespaceKeyword": "error" 35 | } 36 | } 37 | }, 38 | "javascript": { 39 | "formatter": { 40 | "jsxQuoteStyle": "single", 41 | "quoteProperties": "asNeeded", 42 | "trailingCommas": "es5", 43 | "semicolons": "always", 44 | "arrowParentheses": "always", 45 | "bracketSpacing": true, 46 | "bracketSameLine": false, 47 | "quoteStyle": "single", 48 | "attributePosition": "auto" 49 | } 50 | }, 51 | "overrides": [] 52 | } 53 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@commitlint/types').UserConfig} 3 | */ 4 | const config = { 5 | extends: ['@commitlint/config-conventional'], 6 | rules: { 7 | 'subject-case': [ 8 | 0, 9 | 'always', 10 | ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], 11 | ], 12 | }, 13 | }; 14 | 15 | module.exports = config; 16 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/logo.png -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | Material Icon ThemeMaterial Icon ThemePhilipp KiefMaterial Extensions 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "material-icons-browser-extension", 3 | "version": "1.10.4", 4 | "description": "Browser Addon that enhances file browsers of version controls with material icons.", 5 | "main": "src/main.ts", 6 | "author": { 7 | "name": "Material Extensions", 8 | "email": "material-icons-extensions@googlegroups.com", 9 | "url": "https://github.com/material-extensions" 10 | }, 11 | "license": "MIT", 12 | "homepage": "https://github.com/material-extensions/material-icons-browser-extension#readme", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/material-extensions/material-icons-browser-extension.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/material-extensions/material-icons-browser-extension/issues" 19 | }, 20 | "dependencies": { 21 | "@emotion/react": "11.13.3", 22 | "@emotion/styled": "11.13.0", 23 | "@mui/icons-material": "6.1.2", 24 | "@mui/material": "6.1.2", 25 | "material-icon-theme": "5.23.0", 26 | "react": "18.3.1", 27 | "react-dom": "18.3.1", 28 | "selector-observer": "2.1.6", 29 | "webextension-polyfill": "0.12.0" 30 | }, 31 | "devDependencies": { 32 | "@biomejs/biome": "1.8.3", 33 | "@octokit/core": "3.5.1", 34 | "@types/fs-extra": "11.0.4", 35 | "@types/json-stable-stringify": "1.0.36", 36 | "@types/node": "20.14.10", 37 | "@types/react": "18.3.11", 38 | "@types/react-dom": "18.3.0", 39 | "@types/webextension-polyfill": "0.10.7", 40 | "changelogen": "0.6.0", 41 | "dotenv": "16.4.7", 42 | "esbuild": "0.25.0", 43 | "esbuild-sass-plugin": "3.3.1", 44 | "fs-extra": "11.3.0", 45 | "husky": "9.1.7", 46 | "json-stable-stringify": "1.1.1", 47 | "lint-staged": "15.4.3", 48 | "nodemon": "3.1.9", 49 | "npm-run-all": "4.1.5", 50 | "rimraf": "5.0.7", 51 | "sharp": "0.33.4", 52 | "ts-node": "10.9.2", 53 | "typescript": "5.8.2", 54 | "web-ext": "8.2.0" 55 | }, 56 | "scripts": { 57 | "prebuild": "rimraf --glob *.zip ./dist", 58 | "build": "run-s build-languages build-src check-type-safety bundle", 59 | "build-languages": "ts-node ./scripts/build-languages.ts", 60 | "build-src": "ts-node ./scripts/build-src.ts", 61 | "build-src-watch": "nodemon --watch ./src --ext ts,tsx,css,html --exec npm run build-src", 62 | "check-type-safety": "tsc -p ./", 63 | "rebuild-logos": "ts-node ./scripts/build-icons.ts", 64 | "bundle": "run-p bundle-edge bundle-chrome bundle-firefox", 65 | "bundle-edge": "zip -r -j github-material-icons-edge-extension.zip dist/chrome-edge", 66 | "bundle-chrome": "zip -r -j github-material-icons-chrome-extension.zip dist/chrome-edge", 67 | "bundle-firefox": "web-ext -s ./dist/firefox/ -n github-material-icons-firefox-extension.zip -a . build --overwrite-dest", 68 | "update-manifest-version": "ts-node ./scripts/update-manifest-version.ts", 69 | "update-upstream-version": "ts-node ./scripts/update-upstream-version.ts", 70 | "update-versions": "run-s update-upstream-version update-manifest-version", 71 | "lint": "npx @biomejs/biome check --write ./src", 72 | "format": "npx @biomejs/biome format --write ./src", 73 | "prepare": "husky" 74 | }, 75 | "lint-staged": { 76 | "*.ts": "npm run lint", 77 | "*": "npm run format" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", "helpers:pinGitHubActionDigests"], 4 | "ignoreDeps": ["material-icon-theme"], 5 | "packageRules": [ 6 | { 7 | "matchManagers": ["github-actions"], 8 | "groupName": "GitHub Actions workflows", 9 | "groupSlug": "github-actions" 10 | }, 11 | { 12 | "matchManagers": ["npm"], 13 | "groupName": "NPM packages", 14 | "groupSlug": "npm-packages" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /scripts/build-icons.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs-extra'; 4 | import sharp from 'sharp'; 5 | 6 | const svgPath: string = path.resolve(__dirname, '..', 'src', 'logo.svg'); 7 | const iconsPath: string = path.resolve( 8 | __dirname, 9 | '..', 10 | 'src', 11 | 'extensionIcons' 12 | ); 13 | const targetSizes: number[] = [16, 32, 48, 128]; 14 | 15 | // Build extension icons. 16 | fs.ensureDir(iconsPath).then(generateIcons); 17 | 18 | /** 19 | * Generate extension icons. 20 | * 21 | * @since 1.4.0 22 | */ 23 | function generateIcons(): void { 24 | targetSizes.forEach((size: number) => { 25 | sharp(svgPath) 26 | .png() 27 | .resize({ width: size, height: size }) 28 | .toFile(`${iconsPath}/icon-${size}.png`); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /scripts/build-languages.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import * as path from 'path'; 3 | import { Octokit } from '@octokit/core'; 4 | import * as fs from 'fs-extra'; 5 | import stringify from 'json-stable-stringify'; 6 | import { generateManifest, Manifest } from 'material-icon-theme'; 7 | 8 | interface LanguageContribution { 9 | id: string; 10 | extensions?: string[]; 11 | filenames?: string[]; 12 | filenamePatterns?: string[]; 13 | } 14 | 15 | interface Language { 16 | id: string; 17 | extensions: string[]; 18 | filenames: string[]; 19 | } 20 | 21 | const vsDataPath: string = path.resolve(__dirname, '..', 'data'); 22 | const srcPath: string = path.resolve(__dirname, '..', 'src'); 23 | 24 | let index: number = 0; 25 | let total: number; 26 | let manifest: Manifest; 27 | const items: Array<[string, string]> = []; 28 | const contributions: LanguageContribution[] = []; 29 | const languages: Language[] = []; 30 | 31 | const resultsPerPage: number = 100; // max 100 32 | const octokit: Octokit = new Octokit({ 33 | auth: process.env.GITHUB_TOKEN, 34 | }); 35 | // biome-ignore lint/style/useNamingConvention: per_page is a valid name 36 | const query: { page: number; per_page: number; q: string } = { 37 | page: 0, 38 | // biome-ignore lint/style/useNamingConvention: per_page is a valid name 39 | per_page: resultsPerPage, 40 | q: 'contributes languages filename:package.json repo:microsoft/vscode', 41 | }; 42 | const GITHUB_RATELIMIT: number = 6000; 43 | 44 | async function main(): Promise { 45 | await fs.remove(vsDataPath); 46 | await fs.ensureDir(vsDataPath); 47 | await fs.remove(path.resolve(srcPath, 'language-map.json')); 48 | 49 | console.log('[1/8] Generating icon configuration (manifest) file.'); 50 | manifest = generateManifest(); 51 | 52 | console.log( 53 | '[2/8] Querying Github API for official VSC language contributions.' 54 | ); 55 | queryLanguageContributions(); 56 | } 57 | 58 | main(); 59 | 60 | async function queryLanguageContributions(): Promise { 61 | const res = await octokit.request('GET /search/code', query); 62 | if (!res.data) throw new Error(); 63 | query.page = index; 64 | index += 1; 65 | if (!total) total = res.data.total_count; 66 | items.push( 67 | ...res.data.items.map( 68 | (item) => [item.html_url, item.path] as [string, string] 69 | ) 70 | ); 71 | if (resultsPerPage * index >= total) { 72 | console.log('[3/8] Fetching Microsoft language contributions from Github.'); 73 | index = 0; 74 | total = items.length; 75 | items.forEach(([htmlUrl, path]) => 76 | fetchLanguageContribution(htmlUrl, path) 77 | ); 78 | } else { 79 | setTimeout(queryLanguageContributions, GITHUB_RATELIMIT); 80 | } 81 | } 82 | 83 | async function fetchLanguageContribution( 84 | htmlUrl: string, 85 | itemPath: string 86 | ): Promise { 87 | const rawUrl: string = htmlUrl.replace('/blob/', '/raw/'); 88 | const resPath: string = itemPath.replace(/[^/]+$/, 'extension.json'); 89 | const extPath: string = path.join(vsDataPath, resPath); 90 | let extManifest: string; 91 | try { 92 | const response = await fetch(rawUrl, {}); 93 | extManifest = await response.text(); 94 | } catch (reason) { 95 | throw new Error(`${reason}`); 96 | } 97 | try { 98 | await fs.ensureDir(path.dirname(extPath)); 99 | await fs.writeFile(extPath, extManifest, 'utf-8'); 100 | } catch (reason) { 101 | throw new Error(`${reason} (${extPath})`); 102 | } 103 | items[index] = [extPath, extManifest]; 104 | index += 1; 105 | if (index === total) { 106 | console.log('[4/8] Loading VSC language contributions into Node.'); 107 | index = 0; 108 | items.forEach(([extPath, extManifest]) => 109 | loadLanguageContribution(extPath, extManifest) 110 | ); 111 | 112 | console.log( 113 | '[5/8] Processing language contributions for VSC File Icon API compatibility.' 114 | ); 115 | index = 0; 116 | total = contributions.length; 117 | contributions.forEach(processLanguageContribution); 118 | } 119 | } 120 | 121 | function loadLanguageContribution(extPath: string, extManifest: string): void { 122 | let data: any; 123 | try { 124 | data = JSON.parse(extManifest.replace(/#\w+_\w+#/g, '0')); 125 | } catch (error) { 126 | throw new Error(`${error} (${extPath})`); 127 | } 128 | if (!data.contributes?.languages) { 129 | return; 130 | } 131 | contributions.push(...data.contributes.languages); 132 | } 133 | 134 | function processLanguageContribution(contribution: LanguageContribution): void { 135 | const { id, filenamePatterns } = contribution; 136 | let { extensions, filenames } = contribution; 137 | extensions = extensions || []; 138 | filenames = filenames || []; 139 | if (filenamePatterns) { 140 | filenamePatterns.forEach((ptn) => { 141 | if (/^\*\.[^*/?]+$/.test(ptn)) { 142 | extensions?.push(ptn.substring(1)); 143 | } 144 | if (/^[^*/?]+$/.test(ptn)) { 145 | filenames?.push(ptn); 146 | } 147 | }); 148 | } 149 | extensions = extensions 150 | .map((ext) => (ext.charAt(0) === '.' ? ext.substring(1) : ext)) 151 | .filter((ext) => !/\*|\/|\?/.test(ext)); 152 | filenames = filenames.filter((name) => !/\*|\/|\?/.test(name)); 153 | if (!filenames.length && !extensions.length) { 154 | total -= 1; 155 | return; 156 | } 157 | const language: Language | undefined = languages.find( 158 | (lang) => lang.id === id 159 | ); 160 | if (language) { 161 | language.filenames.push(...filenames); 162 | language.extensions.push(...extensions); 163 | } else { 164 | languages.push({ id, extensions, filenames }); 165 | } 166 | index += 1; 167 | if (index === total) { 168 | console.log( 169 | '[6/8] Mapping language contributions into file icon configuration.' 170 | ); 171 | index = 0; 172 | total = languages.length; 173 | languages.forEach(mapLanguageContribution); 174 | } 175 | } 176 | 177 | const languageMap: { 178 | fileExtensions: { [key: string]: string }; 179 | fileNames: { [key: string]: string }; 180 | } = { 181 | fileExtensions: {}, 182 | fileNames: {}, 183 | }; 184 | 185 | function mapLanguageContribution(lang: Language): void { 186 | const langIcon: string | undefined = manifest.languageIds?.[lang.id]; 187 | lang.extensions.forEach((ext) => { 188 | const iconName: string | undefined = 189 | manifest.fileExtensions?.[ext] || langIcon; 190 | if ( 191 | !manifest.fileExtensions?.[ext] && 192 | iconName && 193 | manifest.iconDefinitions?.[iconName] 194 | ) { 195 | languageMap.fileExtensions[ext] = iconName; 196 | } 197 | }); 198 | lang.filenames.forEach((name) => { 199 | const iconName: string | undefined = manifest.fileNames?.[name] || langIcon; 200 | if ( 201 | !manifest.fileNames?.[name] && 202 | !(name.startsWith('.') && manifest.fileExtensions?.[name.substring(1)]) && 203 | iconName && 204 | manifest.iconDefinitions?.[iconName] 205 | ) { 206 | languageMap.fileNames[name] = iconName; 207 | } 208 | }); 209 | index += 1; 210 | if (index === total) { 211 | generateLanguageMap(); 212 | } 213 | } 214 | 215 | async function generateLanguageMap(): Promise { 216 | console.log( 217 | '[7/8] Writing language contribution map to icon configuration file.' 218 | ); 219 | await fs.writeFile( 220 | path.resolve(srcPath, 'language-map.json'), 221 | stringify(languageMap, { space: ' ' }) 222 | ); 223 | console.log('[8/8] Deleting language contribution cache.'); 224 | await fs.remove(vsDataPath); 225 | } 226 | -------------------------------------------------------------------------------- /scripts/build-src.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import * as path from 'path'; 3 | import * as esbuild from 'esbuild'; 4 | import * as fs from 'fs-extra'; 5 | 6 | const destSVGPath: string = path.resolve( 7 | __dirname, 8 | '..', 9 | 'node_modules', 10 | 'material-icon-theme', 11 | 'icons' 12 | ); 13 | const distBasePath: string = path.resolve(__dirname, '..', 'dist'); 14 | const srcPath: string = path.resolve(__dirname, '..', 'src'); 15 | 16 | /** Create icons cache. */ 17 | async function consolidateSVGFiles(): Promise { 18 | console.log('[1/2] Generate icon cache for extension.'); 19 | await fs 20 | .copy(path.resolve(srcPath, 'custom'), destSVGPath) 21 | .then(() => fs.readdir(destSVGPath)) 22 | .then((files) => 23 | Object.fromEntries( 24 | files.map((filename) => [ 25 | // Remove '.clone' and '.svg' from filename 26 | filename 27 | .replace('.clone', '') 28 | .replace('.svg', ''), 29 | filename, 30 | ]) 31 | ) 32 | ) 33 | .then((iconsDict) => 34 | fs.writeJSON(path.resolve(srcPath, 'icon-list.json'), iconsDict) 35 | ); 36 | } 37 | 38 | function bundleJS( 39 | outDir: string, 40 | entryFile: string 41 | ): Promise { 42 | const buildOptions: esbuild.BuildOptions = { 43 | entryPoints: [entryFile], 44 | bundle: true, 45 | minify: true, 46 | sourcemap: false, 47 | outdir: outDir, 48 | loader: { '.svg': 'dataurl' }, 49 | }; 50 | return esbuild.build(buildOptions); 51 | } 52 | 53 | function src( 54 | distPath: string 55 | ): Promise<(void | esbuild.BuildResult | void[])[]> { 56 | console.log('[2/2] Bundle extension manifest, images and main script.'); 57 | 58 | const copyIcons: Promise = fs.copy(destSVGPath, distPath); 59 | 60 | const bundleMainScript = (): Promise => 61 | bundleJS(distPath, path.resolve(srcPath, 'main.ts')); 62 | const bundlePopupScript = (): Promise => 63 | bundleJS( 64 | distPath, 65 | path.resolve(srcPath, 'ui', 'popup', 'settings-popup.tsx') 66 | ); 67 | const bundleOptionsScript = (): Promise => 68 | bundleJS(distPath, path.resolve(srcPath, 'ui', 'options', 'options.tsx')); 69 | 70 | const bundleAll: Promise = bundleMainScript() 71 | .then(bundlePopupScript) 72 | .then(bundleOptionsScript); 73 | 74 | const copyPopup: Promise = Promise.all( 75 | ['settings-popup.html', 'settings-popup.css'].map((file) => 76 | fs.copy( 77 | path.resolve(srcPath, 'ui', 'popup', file), 78 | path.resolve(distPath, file) 79 | ) 80 | ) 81 | ); 82 | 83 | const copyOptions: Promise = Promise.all( 84 | ['options.html', 'options.css'].map((file) => 85 | fs.copy( 86 | path.resolve(srcPath, 'ui', 'options', file), 87 | path.resolve(distPath, file) 88 | ) 89 | ) 90 | ); 91 | 92 | const copyStyles: Promise = fs.copy( 93 | path.resolve(srcPath, 'injected-styles.css'), 94 | path.resolve(distPath, 'injected-styles.css') 95 | ); 96 | 97 | const copyExtensionLogos: Promise = fs.copy( 98 | path.resolve(srcPath, 'extensionIcons'), 99 | distPath 100 | ); 101 | 102 | return Promise.all([ 103 | copyExtensionLogos, 104 | copyOptions, 105 | copyPopup, 106 | copyStyles, 107 | copyIcons, 108 | bundleAll, 109 | ]); 110 | } 111 | 112 | function buildManifest(distPath: string, manifestName: string): Promise { 113 | return Promise.all([ 114 | fs.readJson(path.resolve(srcPath, 'manifests', 'base.json')), 115 | fs.readJson(path.resolve(srcPath, 'manifests', manifestName)), 116 | ]) 117 | .then(([base, custom]) => ({ ...base, ...custom })) 118 | .then((manifest) => 119 | fs.writeJson(path.resolve(distPath, 'manifest.json'), manifest, { 120 | spaces: 2, 121 | }) 122 | ); 123 | } 124 | 125 | function buildDist(name: string, manifestName: string): Promise { 126 | const distPath: string = path.resolve(distBasePath, name); 127 | 128 | return fs 129 | .ensureDir(distPath) 130 | .then(consolidateSVGFiles) 131 | .then(() => src(distPath)) 132 | .then(() => buildManifest(distPath, manifestName)) 133 | .catch(console.error); 134 | } 135 | 136 | buildDist('firefox', 'firefox.json').then(() => 137 | buildDist('chrome-edge', 'chrome-edge.json') 138 | ); 139 | -------------------------------------------------------------------------------- /scripts/update-manifest-version.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs/promises'; 4 | 5 | const packageJsonPath: string = path.resolve(__dirname, '..', 'package.json'); 6 | const manifestPath: string = path.resolve( 7 | __dirname, 8 | '..', 9 | 'src', 10 | 'manifests', 11 | 'base.json' 12 | ); 13 | 14 | const updateManifestVersion = async (): Promise => { 15 | const packageJsonData: string = await fs.readFile(packageJsonPath, { 16 | encoding: 'utf8', 17 | }); 18 | const packageJson = JSON.parse(packageJsonData); 19 | 20 | const manifestData: string = await fs.readFile(manifestPath, { 21 | encoding: 'utf8', 22 | }); 23 | const manifest = JSON.parse(manifestData); 24 | 25 | const updatedManifest = { 26 | ...manifest, 27 | version: packageJson.version, 28 | }; 29 | const updatedManifestStr: string = `${JSON.stringify(updatedManifest, null, 2)}\n`; 30 | 31 | await fs.writeFile(manifestPath, updatedManifestStr); 32 | console.log(`Updated manifest.json version to ${packageJson.version}`); 33 | }; 34 | 35 | updateManifestVersion().catch(console.error); 36 | -------------------------------------------------------------------------------- /scripts/update-upstream-version.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs/promises'; 4 | 5 | /** 6 | * Gets latest version of the Material Icon Theme Module 7 | * 8 | * @returns {Promise} The current version of the upstream repository. 9 | */ 10 | const getUpstreamVersion = async (): Promise => { 11 | const packagePath: string = path.resolve( 12 | __dirname, 13 | '..', 14 | 'node_modules', 15 | 'material-icon-theme', 16 | 'package.json' 17 | ); 18 | const packageData: string = await fs.readFile(packagePath, { 19 | encoding: 'utf8', 20 | }); 21 | const packageJson = JSON.parse(packageData) as { version: string }; 22 | return packageJson.version; 23 | }; 24 | 25 | /** 26 | * Updates the version badge in the README file. 27 | * 28 | * @param {string} version - The new version to update the badge to. 29 | * @returns {Promise} 30 | */ 31 | const updateReadmeBadge = async (version: string): Promise => { 32 | const readmeFilePath: string = path.resolve(__dirname, '..', 'README.md'); 33 | const readme: string = await fs.readFile(readmeFilePath, { 34 | encoding: 'utf8', 35 | }); 36 | const versionRgx: RegExp = /(badge\/[\w_]+-v)\d+\.\d+\.\d+/; 37 | const replacement: string = `$1${version}`; 38 | const updatedReadme: string = readme.replace(versionRgx, replacement); 39 | 40 | await fs.writeFile(readmeFilePath, updatedReadme); 41 | }; 42 | 43 | /** 44 | * Main function to run the update process. 45 | */ 46 | const run = async (): Promise => { 47 | const latestVersion: string = await getUpstreamVersion(); 48 | await updateReadmeBadge(latestVersion); 49 | }; 50 | 51 | run().catch(console.error); 52 | -------------------------------------------------------------------------------- /src/custom/folder-symlink.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/extensionIcons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/src/extensionIcons/icon-128.png -------------------------------------------------------------------------------- /src/extensionIcons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/src/extensionIcons/icon-16.png -------------------------------------------------------------------------------- /src/extensionIcons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/src/extensionIcons/icon-32.png -------------------------------------------------------------------------------- /src/extensionIcons/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/material-extensions/material-icons-browser-extension/0cd74635fb2bed44367bfe683b3f415ce8f104f6/src/extensionIcons/icon-48.png -------------------------------------------------------------------------------- /src/injected-styles.css: -------------------------------------------------------------------------------- 1 | body[data-material-icons-extension-size='sm'] img[data-material-icons-extension='icon'] { 2 | transform: scale(0.875); 3 | } 4 | 5 | body[data-material-icons-extension-size='lg'] img[data-material-icons-extension='icon'] { 6 | transform: scale(1.125); 7 | } 8 | 9 | body[data-material-icons-extension-size='xl'] img[data-material-icons-extension='icon'] { 10 | transform: scale(1.25); 11 | } 12 | 13 | .material-icons-exension-hide-pseudo::before { 14 | display: none !important; 15 | } 16 | 17 | /* Hide folder open/closed icons from new code view tree when clicked by disabling 18 | display of those icons when they immediately follow the replaced icon */ 19 | img[data-material-icons-extension='icon'] + svg.octicon-file-directory-open-fill, 20 | img[data-material-icons-extension='icon'] + svg.octicon-file-directory-fill { 21 | display: none !important; 22 | } 23 | 24 | /* github package icon spacing fix */ 25 | .octicon-package[data-material-icons-extension] { 26 | margin-right: 5px; 27 | } 28 | .octicon-file-zip[data-material-icons-extension] { 29 | margin-right: 5px; 30 | } 31 | 32 | .releases-download-list .item a { 33 | display: flex; 34 | align-items: center; 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/custom-providers.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill'; 2 | import { Provider } from '../models'; 3 | 4 | export const getCustomProviders = (): Promise< 5 | Record Provider) | string> 6 | > => 7 | Browser.storage.sync 8 | .get('customProviders') 9 | .then((data) => data.customProviders || {}); 10 | 11 | export const addCustomProvider = ( 12 | name: string, 13 | handler: (() => Provider) | string 14 | ) => 15 | getCustomProviders().then((customProviders) => { 16 | customProviders[name] = handler; 17 | 18 | return Browser.storage.sync.set({ customProviders }); 19 | }); 20 | 21 | export const removeCustomProvider = (name: string) => { 22 | return getCustomProviders().then((customProviders) => { 23 | delete customProviders[name]; 24 | 25 | Browser.storage.sync.set({ customProviders }); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/lib/icon-sizes.ts: -------------------------------------------------------------------------------- 1 | import { addConfigChangeListener, getConfig } from './user-config'; 2 | 3 | export const iconSizes = ['sm', 'md', 'lg', 'xl']; 4 | export type IconSize = (typeof iconSizes)[number]; 5 | 6 | const ATTRIBUTE_NAME = 'data-material-icons-extension-size'; 7 | 8 | const setSizeAttribute = (iconSize: IconSize) => 9 | document.body.setAttribute(ATTRIBUTE_NAME, iconSize); 10 | 11 | /** 12 | * The mutation observer ensures that the body tag will have the correct attribute 13 | * all the time because some websites (e.g. GitHub) remove it while navigating. 14 | */ 15 | const observeBodyChanges = () => { 16 | const observer = new MutationObserver(() => { 17 | getConfig('iconSize').then((iconSize) => { 18 | if (!document.body.hasAttribute(ATTRIBUTE_NAME)) { 19 | setSizeAttribute(iconSize); 20 | } 21 | }); 22 | }); 23 | 24 | observer.observe(document.body, { attributes: true, subtree: false }); 25 | }; 26 | 27 | export const initIconSizes = () => { 28 | const setIconSize = () => getConfig('iconSize').then(setSizeAttribute); 29 | 30 | document.addEventListener( 31 | 'DOMContentLoaded', 32 | () => { 33 | setIconSize(); 34 | observeBodyChanges(); 35 | }, 36 | false 37 | ); 38 | 39 | addConfigChangeListener('iconSize', setSizeAttribute); 40 | addConfigChangeListener('iconSize', setIconSize, 'default'); 41 | }; 42 | -------------------------------------------------------------------------------- /src/lib/replace-icon.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from 'material-icon-theme'; 2 | import Browser from 'webextension-polyfill'; 3 | import iconsList from '../icon-list.json'; 4 | import languageMap from '../language-map.json'; 5 | import { Provider } from '../models'; 6 | 7 | const iconsListTyped = iconsList as Record; 8 | const languageMapTyped = languageMap as { 9 | fileExtensions: Record; 10 | fileNames: Record; 11 | }; 12 | 13 | export function replaceIconInRow( 14 | itemRow: HTMLElement, 15 | provider: Provider, 16 | manifest: Manifest 17 | ): void { 18 | let fileName = itemRow 19 | .querySelector(provider.selectors.filename) 20 | ?.textContent // get the last folder for the icon 21 | ?.split('/') 22 | .reverse()[0] 23 | // when using textContent, it can add multiple types of whitespace, 24 | // using regex to replace them with a single space, 25 | // can be used later to transform the filename 26 | .replace(/\s+/g, ' ') 27 | .trim(); 28 | if (!fileName) return; 29 | 30 | const iconEl = itemRow.querySelector( 31 | provider.selectors.icon 32 | ) as HTMLElement | null; 33 | if (iconEl?.getAttribute('data-material-icons-extension')) return; 34 | 35 | if (!iconEl) return; 36 | 37 | fileName = provider.transformFileName(itemRow, iconEl, fileName); 38 | 39 | replaceIcon(iconEl, fileName, itemRow, provider, manifest); 40 | } 41 | 42 | function replaceIcon( 43 | iconEl: HTMLElement, 44 | fileName: string, 45 | itemRow: HTMLElement, 46 | provider: Provider, 47 | manifest: Manifest 48 | ): void { 49 | const isDir = provider.getIsDirectory({ row: itemRow, icon: iconEl }); 50 | const isSubmodule = provider.getIsSubmodule({ row: itemRow, icon: iconEl }); 51 | const isSymlink = provider.getIsSymlink({ row: itemRow, icon: iconEl }); 52 | const lowerFileName = fileName.toLowerCase(); 53 | 54 | const fileExtensions: string[] = []; 55 | if (fileName.length <= 255) { 56 | for (let i = 0; i < fileName.length; i += 1) { 57 | if (fileName[i] === '.') fileExtensions.push(lowerFileName.slice(i + 1)); 58 | } 59 | } 60 | 61 | let iconName = lookForMatch( 62 | fileName, 63 | lowerFileName, 64 | fileExtensions, 65 | isDir, 66 | isSubmodule, 67 | isSymlink, 68 | manifest 69 | ); 70 | 71 | const isLightTheme = provider.getIsLightTheme(); 72 | if (isLightTheme) { 73 | iconName = lookForLightMatch( 74 | iconName, 75 | fileName, 76 | fileExtensions, 77 | isDir, 78 | manifest 79 | ); 80 | } 81 | 82 | // get correct icon name from icon list 83 | iconName = iconsListTyped[iconName] ?? (isDir ? 'folder.svg' : 'file.svg'); 84 | 85 | replaceElementWithIcon(iconEl, iconName, fileName, provider); 86 | } 87 | 88 | export function replaceElementWithIcon( 89 | iconEl: HTMLElement, 90 | iconName: string, 91 | fileName: string, 92 | provider: Provider 93 | ): void { 94 | const newSVG = document.createElement('img'); 95 | newSVG.setAttribute('data-material-icons-extension', 'icon'); 96 | newSVG.setAttribute('data-material-icons-extension-iconname', iconName ?? ''); 97 | newSVG.setAttribute('data-material-icons-extension-filename', fileName); 98 | newSVG.src = Browser.runtime.getURL(iconName); 99 | 100 | provider.replaceIcon(iconEl, newSVG); 101 | } 102 | 103 | function lookForMatch( 104 | fileName: string, 105 | lowerFileName: string, 106 | fileExtensions: string[], 107 | isDir: boolean, 108 | isSubmodule: boolean, 109 | isSymlink: boolean, 110 | manifest: Manifest 111 | ): string { 112 | if (isSubmodule) return 'folder-git'; 113 | if (isSymlink) return 'folder-symlink'; 114 | 115 | if (!isDir) { 116 | if (manifest.fileNames?.[fileName]) return manifest.fileNames?.[fileName]; 117 | if (manifest.fileNames?.[lowerFileName]) 118 | return manifest.fileNames?.[lowerFileName]; 119 | 120 | for (const ext of fileExtensions) { 121 | if (manifest.fileExtensions?.[ext]) return manifest.fileExtensions?.[ext]; 122 | if (manifest.languageIds?.[ext]) return manifest.languageIds?.[ext]; 123 | } 124 | 125 | const languageIcon = getLanguageIcon( 126 | fileName, 127 | lowerFileName, 128 | fileExtensions 129 | ); 130 | 131 | if (languageIcon) 132 | return manifest.languageIds?.[languageIcon] ?? languageIcon; 133 | 134 | return 'file'; 135 | } 136 | 137 | if (manifest.folderNames?.[fileName]) return manifest.folderNames?.[fileName]; 138 | if (manifest.folderNames?.[lowerFileName]) 139 | return manifest.folderNames?.[lowerFileName]; 140 | 141 | return 'folder'; 142 | } 143 | 144 | function getLanguageIcon( 145 | fileName: string, 146 | lowerFileName: string, 147 | fileExtensions: string[] 148 | ): string | undefined { 149 | if (languageMapTyped.fileNames[fileName]) 150 | return languageMapTyped.fileNames[fileName]; 151 | if (languageMapTyped.fileNames[lowerFileName]) 152 | return languageMapTyped.fileNames[lowerFileName]; 153 | for (const ext of fileExtensions) { 154 | if (languageMapTyped.fileExtensions[ext]) 155 | return languageMapTyped.fileExtensions[ext]; 156 | } 157 | 158 | return undefined; 159 | } 160 | 161 | function lookForLightMatch( 162 | iconName: string, 163 | fileName: string, 164 | fileExtensions: string[], 165 | isDir: boolean, 166 | manifest: Manifest 167 | ): string { 168 | if (manifest.light?.fileNames?.[fileName] && !isDir) 169 | return manifest.light?.fileNames?.[fileName]; 170 | if (manifest.light?.folderNames?.[fileName] && isDir) 171 | return manifest.light?.folderNames?.[fileName]; 172 | 173 | for (const ext of fileExtensions) { 174 | if (manifest.light?.fileExtensions?.[ext] && !isDir) 175 | return manifest.light?.fileExtensions?.[ext]; 176 | } 177 | 178 | return iconName; 179 | } 180 | -------------------------------------------------------------------------------- /src/lib/replace-icons.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IconAssociations, 3 | IconPackValue, 4 | generateManifest, 5 | } from 'material-icon-theme'; 6 | import { observe } from 'selector-observer'; 7 | import { Provider } from '../models'; 8 | import { replaceElementWithIcon, replaceIconInRow } from './replace-icon'; 9 | 10 | export const observePage = ( 11 | gitProvider: Provider, 12 | iconPack: IconPackValue, 13 | fileBindings?: IconAssociations, 14 | folderBindings?: IconAssociations, 15 | languageBindings?: IconAssociations 16 | ): void => { 17 | const manifest = generateManifest({ 18 | activeIconPack: iconPack || undefined, 19 | files: { associations: fileBindings }, 20 | folders: { associations: folderBindings }, 21 | languages: { 22 | associations: languageBindings, 23 | }, 24 | }); 25 | 26 | observe(gitProvider.selectors.row, { 27 | add(row) { 28 | const callback = () => 29 | replaceIconInRow(row as HTMLElement, gitProvider, manifest); 30 | callback(); 31 | gitProvider.onAdd(row as HTMLElement, callback); 32 | }, 33 | }); 34 | }; 35 | 36 | export const replaceAllIcons = (provider: Provider) => { 37 | document 38 | .querySelectorAll('img[data-material-icons-extension-iconname]') 39 | .forEach((iconEl) => { 40 | const iconName = iconEl.getAttribute( 41 | 'data-material-icons-extension-iconname' 42 | ); 43 | const fileName = 44 | iconEl.getAttribute('data-material-icons-extension-filename') ?? ''; 45 | if (iconName) 46 | replaceElementWithIcon( 47 | iconEl as HTMLElement, 48 | iconName, 49 | fileName, 50 | provider 51 | ); 52 | }); 53 | }; 54 | -------------------------------------------------------------------------------- /src/lib/user-config.ts: -------------------------------------------------------------------------------- 1 | import { IconAssociations, IconPackValue } from 'material-icon-theme'; 2 | import Browser from 'webextension-polyfill'; 3 | import { IconSize } from './icon-sizes'; 4 | 5 | export type UserConfig = { 6 | iconPack: IconPackValue; 7 | iconSize: IconSize; 8 | extEnabled: boolean; 9 | fileIconBindings?: IconAssociations; 10 | folderIconBindings?: IconAssociations; 11 | languageIconBindings?: IconAssociations; 12 | }; 13 | 14 | export const hardDefaults: UserConfig = { 15 | iconPack: 'react', 16 | iconSize: 'md', 17 | extEnabled: true, 18 | fileIconBindings: {}, 19 | folderIconBindings: {}, 20 | languageIconBindings: {}, 21 | }; 22 | 23 | type ConfigValueType = UserConfig[T]; 24 | 25 | export const getConfig = async ( 26 | configName: T, 27 | domain = window.location.hostname, 28 | useDefault = true 29 | ): Promise> => { 30 | const keys = { 31 | [`${domain !== 'default' ? domain : 'SKIP'}:${configName}`]: null, 32 | [`default:${configName}`]: hardDefaults[configName], 33 | }; 34 | 35 | const result = await Browser.storage.sync.get(keys); 36 | const domainSpecificValue = result[`${domain}:${configName}`]; 37 | const defaultValue = result[`default:${configName}`]; 38 | 39 | return domainSpecificValue ?? (useDefault ? defaultValue : null); 40 | }; 41 | 42 | export const setConfig = ( 43 | configName: T, 44 | value: ConfigValueType, 45 | domain = window.location.hostname 46 | ) => { 47 | Browser.storage.sync.set({ 48 | [`${domain}:${configName}`]: value, 49 | }); 50 | }; 51 | 52 | export const clearConfig = ( 53 | configName: keyof UserConfig, 54 | domain = window.location.hostname 55 | ) => Browser.storage.sync.remove(`${domain}:${configName}`); 56 | 57 | export const addConfigChangeListener = ( 58 | configName: keyof UserConfig, 59 | handler: Function, 60 | domain = window.location.hostname 61 | ) => 62 | Browser.storage.onChanged.addListener( 63 | (changes) => 64 | changes[`${domain}:${configName}`]?.newValue !== undefined && 65 | handler(changes[`${domain}:${configName}`]?.newValue) 66 | ); 67 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | Material Icon ThemeMaterial Icon ThemePhilipp KiefMaterial Extensions 2 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { IconPackValue } from 'material-icon-theme'; 2 | import Browser from 'webextension-polyfill'; 3 | import { initIconSizes } from './lib/icon-sizes'; 4 | import { observePage, replaceAllIcons } from './lib/replace-icons'; 5 | import { addConfigChangeListener, getConfig } from './lib/user-config'; 6 | import { Provider } from './models'; 7 | import { getGitProvider } from './providers'; 8 | 9 | interface Possibilities { 10 | [key: string]: string; 11 | } 12 | 13 | const init = async () => { 14 | initIconSizes(); 15 | const { href } = window.location; 16 | await handleProvider(href); 17 | }; 18 | 19 | const handleProvider = async (href: string) => { 20 | const provider: Provider | null = await getGitProvider(href); 21 | if (!provider) return; 22 | 23 | const iconPack = await getConfig('iconPack'); 24 | const fileBindings = await getConfig('fileIconBindings'); 25 | const folderBindings = await getConfig('folderIconBindings'); 26 | const languageBindings = await getConfig('languageIconBindings'); 27 | const extEnabled = await getConfig('extEnabled'); 28 | const globalExtEnabled = await getConfig('extEnabled', 'default'); 29 | 30 | if (!globalExtEnabled || !extEnabled) return; 31 | 32 | observePage( 33 | provider, 34 | iconPack, 35 | fileBindings, 36 | folderBindings, 37 | languageBindings 38 | ); 39 | addConfigChangeListener('iconPack', () => replaceAllIcons(provider)); 40 | }; 41 | 42 | type Handlers = { 43 | init: () => void; 44 | guessProvider: (possibilities: Possibilities) => string | null; 45 | }; 46 | 47 | const handlers: Handlers = { 48 | init, 49 | guessProvider: (possibilities: Possibilities): string | null => { 50 | for (const [name, selector] of Object.entries(possibilities)) { 51 | if (document.querySelector(selector)) { 52 | return name; 53 | } 54 | } 55 | return null; 56 | }, 57 | }; 58 | 59 | Browser.runtime.onMessage.addListener( 60 | ( 61 | message: { cmd: keyof Handlers; args?: unknown[] }, 62 | _: Browser.Runtime.MessageSender, 63 | sendResponse: (response?: any) => void 64 | ) => { 65 | if (!handlers[message.cmd]) { 66 | return sendResponse(null); 67 | } 68 | 69 | if (message.cmd === 'init') { 70 | handlers.init(); 71 | return sendResponse(null); 72 | } 73 | 74 | if (message.cmd === 'guessProvider') { 75 | const result = handlers[message.cmd]( 76 | (message.args || [])[0] as unknown as Possibilities 77 | ); 78 | return sendResponse(result); 79 | } 80 | } 81 | ); 82 | 83 | init(); 84 | -------------------------------------------------------------------------------- /src/manifests/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Material Icons for GitHub", 3 | "version": "1.10.4", 4 | "description": "Material icons for the file browser of popular websites like GitHub, Azure, Bitbucket etc.", 5 | "homepage_url": "https://github.com/material-extensions/material-icons-browser-extension", 6 | "icons": { 7 | "16": "icon-16.png", 8 | "32": "icon-32.png", 9 | "48": "icon-48.png", 10 | "128": "icon-128.png" 11 | }, 12 | "content_scripts": [ 13 | { 14 | "matches": [ 15 | "*://github.com/*", 16 | "*://bitbucket.org/*", 17 | "*://dev.azure.com/*", 18 | "*://*.visualstudio.com/*", 19 | "*://gitea.com/*", 20 | "*://gitlab.com/*", 21 | "*://gitee.com/*", 22 | "*://sourceforge.net/*" 23 | ], 24 | "js": [ 25 | "./main.js" 26 | ], 27 | "css": [ 28 | "./injected-styles.css" 29 | ], 30 | "run_at": "document_start" 31 | } 32 | ], 33 | "options_ui": { 34 | "page": "options.html", 35 | "open_in_tab": true 36 | }, 37 | "permissions": [ 38 | "storage", 39 | "activeTab", 40 | "scripting" 41 | ], 42 | "manifest_version": 3, 43 | "web_accessible_resources": [ 44 | { 45 | "resources": [ 46 | "*.svg" 47 | ], 48 | "matches": [ 49 | "*://github.com/*", 50 | "*://bitbucket.org/*", 51 | "*://dev.azure.com/*", 52 | "*://*.visualstudio.com/*", 53 | "*://gitea.com/*", 54 | "*://gitlab.com/*", 55 | "*://gitee.com/*", 56 | "*://sourceforge.net/*", 57 | "*://*/*" 58 | ] 59 | } 60 | ], 61 | "action": { 62 | "default_title": "Material Icons Settings", 63 | "default_popup": "settings-popup.html", 64 | "default_icon": { 65 | "16": "icon-16.png", 66 | "32": "icon-32.png", 67 | "48": "icon-48.png", 68 | "128": "icon-128.png" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/manifests/chrome-edge.json: -------------------------------------------------------------------------------- 1 | { 2 | "optional_host_permissions": ["*://*/*"] 3 | } 4 | -------------------------------------------------------------------------------- /src/manifests/firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "browser_specific_settings": { 3 | "gecko": { 4 | "id": "{eac6e624-97fa-4f28-9d24-c06c9b8aa713}" 5 | } 6 | }, 7 | "optional_permissions": ["activeTab", ""] 8 | } 9 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './provider'; 2 | -------------------------------------------------------------------------------- /src/models/provider.ts: -------------------------------------------------------------------------------- 1 | export type Provider = { 2 | name: string; 3 | domains: { host: string; test: RegExp }[]; 4 | selectors: { 5 | filename: string; 6 | icon: string; 7 | row: string; 8 | detect: string | null; 9 | }; 10 | canSelfHost: boolean; 11 | isCustom: boolean; 12 | onAdd: (row: HTMLElement, callback: () => void) => void; 13 | getIsDirectory: (params: { row: HTMLElement; icon: HTMLElement }) => boolean; 14 | getIsSubmodule: (params: { row: HTMLElement; icon: HTMLElement }) => boolean; 15 | getIsSymlink: (params: { row: HTMLElement; icon: HTMLElement }) => boolean; 16 | getIsLightTheme: () => boolean; 17 | replaceIcon: (oldIcon: HTMLElement, newIcon: HTMLElement) => void; 18 | transformFileName: ( 19 | rowEl: HTMLElement, 20 | iconEl: HTMLElement, 21 | fileName: string 22 | ) => string; 23 | }; 24 | 25 | export type Domain = Pick & { 26 | isDefault: boolean; 27 | }; 28 | 29 | export type ProviderMap = Record; 30 | -------------------------------------------------------------------------------- /src/providers/azure.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '../models'; 2 | 3 | /** The name of the class used to hide the pseudo element `:before` on Azure */ 4 | const HIDE_PSEUDO_CLASS = 'material-icons-exension-hide-pseudo'; 5 | 6 | export default function azure(): Provider { 7 | return { 8 | name: 'azure', 9 | domains: [ 10 | { 11 | host: 'dev.azure.com', 12 | test: /^dev\.azure\.com$/, 13 | }, 14 | { 15 | host: 'visualstudio.com', 16 | test: /.*\.visualstudio\.com$/, 17 | }, 18 | ], 19 | selectors: { 20 | row: 'table.bolt-table tbody tr.bolt-table-row, table.bolt-table tbody > a', 21 | filename: 22 | 'td.bolt-table-cell[data-column-index="0"] .bolt-table-link .text-ellipsis, table.bolt-table tbody > a > td[aria-colindex="1"] span.text-ellipsis', 23 | icon: 'td.bolt-table-cell[data-column-index="0"] span.icon-margin, td[aria-colindex="1"] span.icon-margin', 24 | // Element by which to detect if the tested domain is azure. 25 | detect: 'body > input[type=hidden][name=__RequestVerificationToken]', 26 | }, 27 | canSelfHost: false, 28 | isCustom: false, 29 | getIsLightTheme: () => 30 | document.defaultView 31 | ?.getComputedStyle(document.body) 32 | .getPropertyValue('color') === 'rgba(0, 0, 0, 0.9)', // TODO: There is probably a better way to determine whether Azure is in light mode 33 | getIsDirectory: ({ icon }) => icon.classList.contains('repos-folder-icon'), 34 | getIsSubmodule: () => false, // There appears to be no way to tell if a folder is a submodule 35 | getIsSymlink: ({ icon }) => 36 | icon.classList.contains('ms-Icon--PageArrowRight'), 37 | replaceIcon: (svgEl, newSVG) => { 38 | newSVG.style.display = 'inline-flex'; 39 | newSVG.style.height = '1rem'; 40 | newSVG.style.width = '1rem'; 41 | 42 | if (!svgEl.classList.contains(HIDE_PSEUDO_CLASS)) { 43 | svgEl.classList.add(HIDE_PSEUDO_CLASS); 44 | } 45 | 46 | // Instead of replacing the child icon, add the new icon as a child, 47 | // otherwise Azure DevOps crashes when you navigate through the repository 48 | if (svgEl.hasChildNodes() && svgEl.firstChild !== null) { 49 | svgEl.replaceChild(newSVG, svgEl.firstChild); 50 | } else { 51 | svgEl.appendChild(newSVG); 52 | } 53 | }, 54 | onAdd: (row, callback) => { 55 | // Mutation observer is required for azure to work properly because the rows are not removed 56 | // from the page when navigating through the repository. Without this the page will render 57 | // fine initially but any subsequent changes will reult in inaccurate icons. 58 | const mutationCallback = (mutationsList: MutationRecord[]) => { 59 | // Check whether the mutation was made by this extension 60 | // this is determined by whether there is an image node added to the dom 61 | const isExtensionMutation = mutationsList.some((mutation) => 62 | Array.from(mutation.addedNodes).some( 63 | (node) => node.nodeName === 'IMG' 64 | ) 65 | ); 66 | 67 | // If the mutation was not caused by the extension, run the icon replacement 68 | // otherwise there will be an infinite loop 69 | if (!isExtensionMutation) { 70 | callback(); 71 | } 72 | }; 73 | 74 | const observer = new MutationObserver(mutationCallback); 75 | observer.observe(row, { 76 | attributes: true, 77 | childList: true, 78 | subtree: true, 79 | }); 80 | }, 81 | transformFileName: ( 82 | _rowEl: HTMLElement, 83 | _iconEl: HTMLElement, 84 | fileName: string 85 | ): string => { 86 | return fileName; 87 | }, 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/providers/bitbucket.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '../models'; 2 | 3 | export default function bitbucket(): Provider { 4 | return { 5 | name: 'bitbucket', 6 | domains: [ 7 | { 8 | host: 'bitbucket.org', 9 | test: /^bitbucket\.org$/, 10 | }, 11 | ], 12 | selectors: { 13 | // Don't replace the icon for the parent directory row 14 | row: 'table[data-qa="repository-directory"] td:first-child a:first-child:not([aria-label="Parent directory,"])', 15 | filename: 'span', 16 | icon: 'svg', 17 | // Element by which to detect if the tested domain is bitbucket. 18 | detect: 'body[data-aui-version] > #root', 19 | }, 20 | canSelfHost: true, 21 | isCustom: false, 22 | getIsLightTheme: () => true, // No dark mode available for bitbucket currently 23 | getIsDirectory: ({ icon }) => 24 | (icon.parentNode as HTMLElement)?.getAttribute('aria-label') === 25 | 'Directory,', 26 | getIsSubmodule: ({ icon }) => 27 | (icon.parentNode as HTMLElement)?.getAttribute('aria-label') === 28 | 'Submodule,', 29 | getIsSymlink: () => false, // There appears to be no way to determine this for bitbucket 30 | replaceIcon: (svgEl, newSVG) => { 31 | newSVG.style.overflow = 'hidden'; 32 | newSVG.style.pointerEvents = 'none'; 33 | newSVG.style.maxHeight = '100%'; 34 | newSVG.style.maxWidth = '100%'; 35 | newSVG.style.verticalAlign = 'bottom'; 36 | 37 | svgEl 38 | .getAttributeNames() 39 | .forEach( 40 | (attr) => 41 | attr !== 'src' && 42 | !/^data-material-icons-extension/.test(attr) && 43 | newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '') 44 | ); 45 | 46 | svgEl.parentNode?.replaceChild(newSVG, svgEl); 47 | }, 48 | onAdd: () => {}, 49 | transformFileName: ( 50 | _rowEl: HTMLElement, 51 | _iconEl: HTMLElement, 52 | fileName: string 53 | ): string => { 54 | return fileName; 55 | }, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/providers/gitea.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '../models'; 2 | 3 | export default function gitea(): Provider { 4 | return { 5 | name: 'gitea', 6 | domains: [ 7 | { 8 | host: 'gitea.com', 9 | test: /^gitea\.com$/, 10 | }, 11 | ], 12 | selectors: { 13 | row: '#repo-files-table .repo-file-item', 14 | filename: '.repo-file-cell.name a', 15 | icon: '.repo-file-cell.name svg', 16 | // Element by which to detect if the tested domain is gitea. 17 | detect: 'body > .full.height > .page-content[role=main]', 18 | }, 19 | canSelfHost: true, 20 | isCustom: false, 21 | getIsLightTheme: () => false, 22 | getIsDirectory: ({ icon }) => 23 | icon.classList.contains('octicon-file-directory-fill'), 24 | getIsSubmodule: ({ icon }) => 25 | icon.classList.contains('octicon-file-submodule'), 26 | getIsSymlink: ({ icon }) => 27 | icon.classList.contains('octicon-file-symlink-file'), 28 | replaceIcon: (svgEl, newSVG) => { 29 | svgEl 30 | .getAttributeNames() 31 | .forEach( 32 | (attr) => 33 | attr !== 'src' && 34 | !/^data-material-icons-extension/.test(attr) && 35 | newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '') 36 | ); 37 | 38 | svgEl.parentNode?.replaceChild(newSVG, svgEl); 39 | }, 40 | onAdd: () => {}, 41 | transformFileName: ( 42 | rowEl: HTMLElement, 43 | _iconEl: HTMLElement, 44 | fileName: string 45 | ): string => { 46 | // try to match the 'Source code (zip)' type of rows in releases page in github. 47 | if ( 48 | rowEl.querySelector('.archive-link') && 49 | fileName.includes('Source code') 50 | ) { 51 | return fileName.replace(/\s+\((.*?)\)$/, '.$1'); 52 | } 53 | 54 | return fileName; 55 | }, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/providers/gitee.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '../models'; 2 | 3 | export default function gitee(): Provider { 4 | return { 5 | name: 'gitee', 6 | domains: [ 7 | { 8 | host: 'gitee.com', 9 | test: /^gitee\.com$/, 10 | }, 11 | ], 12 | selectors: { 13 | // File list row, README header, file view header 14 | row: `#git-project-content .tree-content .row.tree-item, 15 | .file_title, 16 | .blob-description, 17 | .release-body .releases-download-list .item`, 18 | // File name table cell, Submodule name table cell, file view header 19 | filename: `.tree-list-item > a, 20 | .tree-item-submodule-name a, 21 | span.file_name, 22 | a`, 23 | // The iconfont icon not including the delete button icon in the file view header 24 | icon: 'i.iconfont:not(.icon-delete), i.icon', 25 | // Element by which to detect if the tested domain is gitee. 26 | detect: null, 27 | }, 28 | canSelfHost: false, 29 | isCustom: false, 30 | getIsLightTheme: () => true, // There appears to be no dark theme available for gitee. 31 | getIsDirectory: ({ icon }) => icon.classList.contains('icon-folders'), 32 | getIsSubmodule: ({ icon }) => icon.classList.contains('icon-submodule'), 33 | getIsSymlink: ({ icon }) => icon.classList.contains('icon-file-shortcut'), 34 | replaceIcon: (svgEl, newSVG) => { 35 | svgEl 36 | .getAttributeNames() 37 | .forEach( 38 | (attr) => 39 | attr !== 'src' && 40 | !/^data-material-icons-extension/.test(attr) && 41 | newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '') 42 | ); 43 | 44 | newSVG.style.height = '28px'; 45 | newSVG.style.width = '18px'; 46 | 47 | svgEl.parentNode?.replaceChild(newSVG, svgEl); 48 | }, 49 | onAdd: () => {}, 50 | transformFileName: ( 51 | rowEl: HTMLElement, 52 | _iconEl: HTMLElement, 53 | fileName: string 54 | ): string => { 55 | // try to match the 'Source code (zip)' type of rows in releases page in github. 56 | if ( 57 | rowEl.classList.contains('item') && 58 | fileName.includes('Source code') 59 | ) { 60 | return fileName.replace(/\s+\((.*?)\)$/, '.$1'); 61 | } 62 | 63 | return fileName; 64 | }, 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/providers/github.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '../models'; 2 | 3 | export default function github(): Provider { 4 | return { 5 | name: 'github', 6 | domains: [ 7 | { 8 | host: 'github.com', 9 | test: /^github\.com$/, 10 | }, 11 | ], 12 | selectors: { 13 | row: `.js-navigation-container[role=grid] > .js-navigation-item, 14 | file-tree .ActionList-content, 15 | a.tree-browser-result, 16 | .PRIVATE_TreeView-item-content, 17 | .react-directory-filename-column, 18 | .Box details .Box-row`, 19 | filename: `div[role="rowheader"] > span, 20 | .ActionList-item-label, 21 | a.tree-browser-result > marked-text, 22 | .PRIVATE_TreeView-item-content > .PRIVATE_TreeView-item-content-text, 23 | .react-directory-filename-column a, 24 | a.Truncate`, 25 | icon: `.octicon-file, 26 | .octicon-file-directory-fill, 27 | .octicon-file-directory-open-fill, 28 | .octicon-file-submodule, 29 | .react-directory-filename-column > svg, 30 | .octicon-package, 31 | .octicon-file-zip`, 32 | // Element by which to detect if the tested domain is github. 33 | detect: 'body > div[data-turbo-body]', 34 | }, 35 | canSelfHost: true, 36 | isCustom: false, 37 | getIsLightTheme: () => { 38 | const colorMode = document 39 | .querySelector('html') 40 | ?.getAttribute('data-color-mode'); 41 | 42 | if (colorMode === 'light') { 43 | return true; 44 | } 45 | 46 | if (colorMode === 'auto') { 47 | return window.matchMedia('(prefers-color-scheme: light)').matches; 48 | } 49 | 50 | return false; 51 | }, 52 | getIsDirectory: ({ icon }) => 53 | icon.getAttribute('aria-label') === 'Directory' || 54 | icon.classList.contains('octicon-file-directory-fill') || 55 | icon.classList.contains('octicon-file-directory-open-fill') || 56 | icon.classList.contains('icon-directory'), 57 | getIsSubmodule: ({ icon }) => 58 | icon.getAttribute('aria-label') === 'Submodule', 59 | getIsSymlink: ({ icon }) => 60 | icon.getAttribute('aria-label') === 'Symlink Directory', 61 | replaceIcon: (svgEl, newSVG) => { 62 | svgEl 63 | .getAttributeNames() 64 | .forEach( 65 | (attr) => 66 | attr !== 'src' && 67 | !/^data-material-icons-extension/.test(attr) && 68 | newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '') 69 | ); 70 | 71 | const prevEl = svgEl.previousElementSibling; 72 | if (prevEl?.getAttribute('data-material-icons-extension') === 'icon') { 73 | newSVG.replaceWith(prevEl); 74 | } 75 | // If the icon to replace is an icon from this extension, replace it with the new icon 76 | else if (svgEl.getAttribute('data-material-icons-extension') === 'icon') { 77 | svgEl.replaceWith(newSVG); 78 | } 79 | // If neither of the above, prepend the new icon in front of the original icon. 80 | // If we remove the icon, GitHub code view crashes when you navigate through the 81 | // tree view. Instead, we just hide it via `style` attribute (not CSS class) 82 | // https://github.com/material-extensions/material-icons-browser-extension/pull/66 83 | else { 84 | svgEl.style.display = 'none'; 85 | svgEl.before(newSVG); 86 | } 87 | }, 88 | onAdd: () => {}, 89 | transformFileName: ( 90 | rowEl: HTMLElement, 91 | _iconEl: HTMLElement, 92 | fileName: string 93 | ): string => { 94 | // remove possible sha from submodule 95 | // matches 4 or more to future proof in case they decide to increase it. 96 | if (fileName.includes('@')) { 97 | return fileName.replace(/\s+@\s+[a-fA-F0-9]{4,}$/, ''); 98 | } 99 | 100 | // try to match the 'Source code (zip)' type of rows in releases page in github. 101 | if ( 102 | rowEl.classList.contains('Box-row') && 103 | fileName.includes('Source code') 104 | ) { 105 | return fileName.replace(/\s+\((.*?)\)$/, '.$1'); 106 | } 107 | 108 | return fileName; 109 | }, 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/providers/gitlab.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '../models'; 2 | 3 | export default function gitlab(): Provider { 4 | return { 5 | name: 'gitlab', 6 | domains: [ 7 | { 8 | host: 'gitlab.com', 9 | test: /^gitlab\.com$/, 10 | }, 11 | ], 12 | selectors: { 13 | // Row in file list, file view header 14 | row: `table[data-testid="file-tree-table"].table.tree-table tr.tree-item, 15 | table[data-qa-selector="file_tree_table"] tr, 16 | .file-header-content, 17 | .gl-card[data-testid="release-block"] .js-assets-list ul li`, 18 | // Cell in file list, file view header, readme header 19 | filename: `.tree-item-file-name .tree-item-link, 20 | .tree-item-file-name, 21 | .file-header-content .file-title-name, 22 | .file-header-content .gl-link, 23 | .gl-link`, 24 | // Any icon not contained in a button 25 | icon: `.tree-item-file-name .tree-item-link svg, 26 | .tree-item svg, .file-header-content svg:not(.gl-button-icon), 27 | .gl-link svg.gl-icon[data-testid="doc-code-icon"]`, 28 | // Element by which to detect if the tested domain is gitlab. 29 | detect: 'head meta[content="GitLab"]', 30 | }, 31 | canSelfHost: true, 32 | isCustom: false, 33 | getIsLightTheme: () => 34 | !document.querySelector('body')?.classList.contains('gl-dark'), 35 | getIsDirectory: ({ icon }) => 36 | icon.getAttribute('data-testid') === 'folder-icon', 37 | getIsSubmodule: ({ row }) => 38 | row.querySelector('a')?.classList.contains('is-submodule') || false, 39 | getIsSymlink: ({ icon }) => 40 | icon.getAttribute('data-testid') === 'symlink-icon', 41 | replaceIcon: (svgEl, newSVG) => { 42 | svgEl 43 | .getAttributeNames() 44 | .forEach( 45 | (attr) => 46 | attr !== 'src' && 47 | !/^data-material-icons-extension/.test(attr) && 48 | newSVG.setAttribute(attr, svgEl.getAttribute(attr) ?? '') 49 | ); 50 | 51 | newSVG.style.height = '16px'; 52 | newSVG.style.width = '16px'; 53 | 54 | svgEl.parentNode?.replaceChild(newSVG, svgEl); 55 | }, 56 | onAdd: () => {}, 57 | transformFileName: ( 58 | rowEl: HTMLElement, 59 | _iconEl: HTMLElement, 60 | fileName: string 61 | ): string => { 62 | // try to match the 'Source code (zip)' type of rows in releases page in github. 63 | if ( 64 | rowEl.parentElement?.parentElement?.classList.contains( 65 | 'js-assets-list' 66 | ) && 67 | fileName.includes('Source code') 68 | ) { 69 | return fileName.replace(/\s+\((.*?)\)$/, '.$1'); 70 | } 71 | 72 | return fileName; 73 | }, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | import { getCustomProviders } from '../lib/custom-providers'; 2 | import { Provider } from '../models'; 3 | import azure from './azure'; 4 | import bitbucket from './bitbucket'; 5 | import gitea from './gitea'; 6 | import gitee from './gitee'; 7 | import github from './github'; 8 | import gitlab from './gitlab'; 9 | import sourceforge from './sourceforge'; 10 | 11 | export const providers: Record Provider> = { 12 | azure, 13 | bitbucket, 14 | gitea, 15 | gitee, 16 | github, 17 | gitlab, 18 | sourceforge, 19 | }; 20 | 21 | export const providerConfig: Record = {}; 22 | 23 | for (const provider of Object.values(providers)) { 24 | const cfg = provider(); 25 | 26 | providerConfig[cfg.name] = cfg; 27 | } 28 | 29 | function regExpEscape(value: string) { 30 | return value.replace(/[-[\]{}()*+!<=:?./\\^$|#\s,]/g, '\\$&'); 31 | } 32 | 33 | /** 34 | * Add custom git provider 35 | */ 36 | export const addGitProvider = ( 37 | name: string, 38 | handler: (() => Provider) | string 39 | ) => { 40 | handler = typeof handler === 'string' ? providers[handler] : handler; 41 | 42 | const provider = handler(); 43 | provider.isCustom = true; 44 | provider.name = name; 45 | provider.domains = [ 46 | { 47 | host: name, 48 | test: new RegExp(`^${regExpEscape(name)}$`), 49 | }, 50 | ]; 51 | 52 | providerConfig[name] = provider; 53 | }; 54 | 55 | export const removeGitProvider = (name: string) => { 56 | delete providerConfig[name]; 57 | }; 58 | 59 | export const getGitProviders = () => 60 | getCustomProviders().then((customProviders) => { 61 | for (const [domain, handler] of Object.entries(customProviders)) { 62 | if (!providerConfig[domain]) { 63 | addGitProvider(domain, handler); 64 | } 65 | } 66 | 67 | return providerConfig; 68 | }); 69 | 70 | /** 71 | * Get all selectors and functions specific to the Git provider 72 | */ 73 | export const getGitProvider = (domain: string) => { 74 | if (!domain.startsWith('http')) { 75 | domain = new URL(`http://${domain}`).host; 76 | } else { 77 | domain = new URL(domain).host; 78 | } 79 | 80 | return getGitProviders().then((p) => { 81 | for (const provider of Object.values(p)) { 82 | for (const d of provider.domains) { 83 | if (d.test.test(domain)) { 84 | return provider; 85 | } 86 | } 87 | } 88 | 89 | return null; 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /src/providers/sourceforge.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '../models'; 2 | 3 | export default function sourceforge(): Provider { 4 | return { 5 | name: 'sourceforge', 6 | domains: [ 7 | { 8 | host: 'sourceforge.net', 9 | test: /^sourceforge\.net$/, 10 | }, 11 | ], 12 | selectors: { 13 | // File list row, README header, file view header 14 | row: 'table#files_list tr, #content_base tr td:first-child', 15 | // File name table cell, file view header 16 | filename: 'th[headers="files_name_h"], td:first-child > a.icon', 17 | // The iconfont icon not including the delete button icon in the file view header 18 | icon: 'th[headers="files_name_h"] > a, a.icon > i.fa', 19 | // Element by which to detect if the tested domain is sourceforge. 20 | detect: null, 21 | }, 22 | canSelfHost: false, 23 | isCustom: false, 24 | getIsLightTheme: () => true, // There appears to be no dark theme available for sourceforge. 25 | getIsDirectory: ({ row, icon }) => { 26 | if (icon.nodeName === 'I') { 27 | return icon.classList.contains('fa-folder'); 28 | } 29 | 30 | return row.classList.contains('folder'); 31 | }, 32 | getIsSubmodule: () => false, 33 | getIsSymlink: ({ icon }) => { 34 | if (icon.nodeName === 'I') { 35 | return icon.classList.contains('fa-star'); 36 | } 37 | 38 | return false; 39 | }, 40 | replaceIcon: (iconOrAnchor, newSVG) => { 41 | newSVG.style.verticalAlign = 'text-bottom'; 42 | 43 | if (iconOrAnchor.nodeName === 'I') { 44 | newSVG.style.height = '14px'; 45 | newSVG.style.width = '14px'; 46 | 47 | iconOrAnchor.parentNode?.replaceChild(newSVG, iconOrAnchor); 48 | } 49 | // For the files list, use the anchor element instead of the icon because in some cases there is no icon 50 | else { 51 | if ( 52 | iconOrAnchor.querySelector( 53 | 'img[data-material-icons-extension="icon"]' 54 | ) 55 | ) { 56 | // only replace/prepend the icon once 57 | return; 58 | } 59 | 60 | newSVG.style.height = '20px'; 61 | newSVG.style.width = '20px'; 62 | 63 | const svgEl = iconOrAnchor.querySelector('svg'); 64 | 65 | if (svgEl) { 66 | svgEl.parentNode?.replaceChild(newSVG, svgEl); 67 | } else { 68 | iconOrAnchor.prepend(newSVG); 69 | } 70 | } 71 | }, 72 | onAdd: () => {}, 73 | transformFileName: ( 74 | _rowEl: HTMLElement, 75 | _iconEl: HTMLElement, 76 | fileName: string 77 | ): string => { 78 | return fileName; 79 | }, 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/ui/options/api/domains.ts: -------------------------------------------------------------------------------- 1 | import { Domain } from '@/models'; 2 | import { getGitProviders } from '@/providers'; 3 | 4 | export function getDomains(): Promise { 5 | return getGitProviders().then((providers) => [ 6 | { name: 'default', isCustom: false, isDefault: true }, 7 | ...Object.values(providers).flatMap((p) => 8 | p.domains.map((d) => ({ 9 | name: d.host, 10 | isCustom: p.isCustom, 11 | isDefault: false, 12 | })) 13 | ), 14 | ]); 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/options/api/icons.ts: -------------------------------------------------------------------------------- 1 | import iconsList from '../../../icon-list.json'; 2 | 3 | const iconsListTyped = iconsList as Record; 4 | const blacklist = ['_light', '_highContrast']; 5 | 6 | function isNotBlacklisted(name: string): boolean { 7 | return !blacklist.some((term) => name.includes(term)); 8 | } 9 | 10 | function filterIcons(predicate: (name: string) => boolean): string[] { 11 | return Object.keys(iconsListTyped).filter(predicate).sort(); 12 | } 13 | 14 | export function getIconFileName( 15 | iconName: string, 16 | isLightMode: boolean 17 | ): string { 18 | const lightIconName = `${iconName}_light`; 19 | if (isLightMode && iconsListTyped[lightIconName]) { 20 | return iconsListTyped[lightIconName]; 21 | } 22 | return iconsListTyped[iconName]; 23 | } 24 | 25 | export function getListOfFileIcons(): string[] { 26 | return filterIcons( 27 | (name) => !name.startsWith('folder') && isNotBlacklisted(name) 28 | ); 29 | } 30 | 31 | export function getListOfFolderIcons(): string[] { 32 | return filterIcons( 33 | (name) => 34 | name.startsWith('folder') && 35 | !name.includes('-open') && 36 | !name.includes('-root') && 37 | isNotBlacklisted(name) 38 | ).map((name) => name.replace('folder-', '')); 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/options/api/language-ids.ts: -------------------------------------------------------------------------------- 1 | import languageMap from '../../../language-map.json'; 2 | const languageMapTyped = languageMap as { 3 | fileExtensions: Record; 4 | fileNames: Record; 5 | }; 6 | 7 | /** 8 | * Get list of all supported language ids. 9 | * 10 | * @returns a list of language ids 11 | */ 12 | export function getLanguageIds(): string[] { 13 | return Object.values(languageMapTyped.fileExtensions) 14 | .concat(Object.values(languageMapTyped.fileNames)) 15 | .reduce((acc, curr) => { 16 | if (!acc.includes(curr)) { 17 | acc.push(curr); 18 | } 19 | return acc; 20 | }, [] as string[]) 21 | .sort(); 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/options/components/confirm-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogActions, 5 | DialogContent, 6 | DialogContentText, 7 | DialogTitle, 8 | } from '@mui/material'; 9 | 10 | type ConfirmDialogProps = { 11 | title: string; 12 | message: string; 13 | show: boolean; 14 | onConfirm: () => void; 15 | onCancel: () => void; 16 | }; 17 | 18 | export function ConfirmDialog({ 19 | title, 20 | message, 21 | show, 22 | onConfirm, 23 | onCancel, 24 | }: ConfirmDialogProps) { 25 | return ( 26 | 27 | {title} 28 | 29 | {message} 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/ui/options/components/domain-actions.tsx: -------------------------------------------------------------------------------- 1 | import { Domain } from '@/models'; 2 | import DeleteIcon from '@mui/icons-material/Delete'; 3 | import SettingsIcon from '@mui/icons-material/Settings'; 4 | import { IconButton, Tooltip } from '@mui/material'; 5 | import { useState } from 'react'; 6 | import { ConfirmDialog } from './confirm-dialog'; 7 | import { IconSettingsDialog } from './icon-settings/icon-settings-dialog'; 8 | 9 | export function DomainActions({ 10 | domain, 11 | deleteDomain, 12 | }: { domain: Domain; deleteDomain?: () => void }) { 13 | const [showConfirmDialog, setShowConfirmDialog] = useState(false); 14 | const [showSettingsDialog, setShowSettingsDialog] = useState(false); 15 | 16 | return ( 17 |
18 | 19 | { 21 | setShowSettingsDialog(true); 22 | }} 23 | > 24 | 25 | 26 | 27 | {domain.isCustom ? ( 28 | 29 | { 31 | setShowConfirmDialog(true); 32 | }} 33 | > 34 | 35 | 36 | 37 | ) : null} 38 | 39 | { 43 | deleteDomain?.(); 44 | setShowConfirmDialog(false); 45 | }} 46 | onCancel={() => { 47 | setShowConfirmDialog(false); 48 | }} 49 | show={showConfirmDialog} 50 | /> 51 | 52 | { 55 | setShowSettingsDialog(false); 56 | }} 57 | show={showSettingsDialog} 58 | /> 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/options/components/domain-name.tsx: -------------------------------------------------------------------------------- 1 | import { Domain } from '@/models'; 2 | import PublicIcon from '@mui/icons-material/Public'; 3 | import { Typography } from '@mui/material'; 4 | import { CSSProperties } from 'react'; 5 | 6 | export function DomainName({ domain }: { domain: Domain }) { 7 | const styles: CSSProperties = { 8 | display: 'flex', 9 | alignItems: 'center', 10 | gap: '.5rem', 11 | fontWeight: 600, 12 | }; 13 | 14 | return ( 15 | 16 |
17 | 18 | {domain.name} 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/options/components/domain-settings.tsx: -------------------------------------------------------------------------------- 1 | import { IconSize } from '@/lib/icon-sizes'; 2 | import { 3 | clearConfig, 4 | getConfig, 5 | hardDefaults, 6 | setConfig, 7 | } from '@/lib/user-config'; 8 | import { Domain } from '@/models'; 9 | import { IconPackValue } from 'material-icon-theme'; 10 | import { CSSProperties, useEffect, useState } from 'react'; 11 | import { DomainSettingsControls } from '../../shared/domain-settings-controls'; 12 | import { DomainActions } from './domain-actions'; 13 | import { DomainName } from './domain-name'; 14 | 15 | export function DomainSettings({ 16 | domain, 17 | deleteDomain, 18 | }: { domain: Domain; deleteDomain?: () => void }) { 19 | const [iconSize, setIconSize] = useState( 20 | hardDefaults.iconSize 21 | ); 22 | const [iconPack, setIconPack] = useState( 23 | hardDefaults.iconPack 24 | ); 25 | const [extensionEnabled, setExtensionEnabled] = useState( 26 | hardDefaults.extEnabled 27 | ); 28 | 29 | useEffect(() => { 30 | getConfig('iconSize', domain.name, false).then(setIconSize); 31 | getConfig('iconPack', domain.name, false).then(setIconPack); 32 | getConfig('extEnabled', domain.name, false).then(setExtensionEnabled); 33 | 34 | const handleResetAllDomains = (event: Event) => { 35 | if (event.type === 'RESET_ALL_DOMAINS') { 36 | resetToDefaults(); 37 | } 38 | }; 39 | 40 | if (domain.name !== 'default') { 41 | window.addEventListener('RESET_ALL_DOMAINS', handleResetAllDomains); 42 | 43 | // return cleanup function 44 | return () => { 45 | window.removeEventListener('RESET_ALL_DOMAINS', handleResetAllDomains); 46 | }; 47 | } 48 | }, []); 49 | 50 | const changeIconSize = (iconSize: IconSize) => { 51 | setConfig('iconSize', iconSize, domain.name); 52 | setIconSize(iconSize); 53 | }; 54 | 55 | const changeIconPack = (iconPack: IconPackValue) => { 56 | setConfig('iconPack', iconPack, domain.name); 57 | setIconPack(iconPack); 58 | }; 59 | 60 | const changeVisibility = (visible: boolean) => { 61 | setConfig('extEnabled', visible, domain.name); 62 | setExtensionEnabled(visible); 63 | }; 64 | 65 | const resetToDefaults = async () => { 66 | await clearConfig('iconSize', domain.name); 67 | await clearConfig('iconPack', domain.name); 68 | await clearConfig('extEnabled', domain.name); 69 | await clearConfig('languageIconBindings', domain.name); 70 | await clearConfig('fileIconBindings', domain.name); 71 | await clearConfig('folderIconBindings', domain.name); 72 | 73 | setIconSize(undefined); 74 | setIconPack(undefined); 75 | setExtensionEnabled(hardDefaults.extEnabled); 76 | }; 77 | 78 | const [windowWidth, setWindowWidth] = useState(window.innerWidth); 79 | 80 | useEffect(() => { 81 | const handleResize = () => setWindowWidth(window.innerWidth); 82 | window.addEventListener('resize', handleResize); 83 | return () => window.removeEventListener('resize', handleResize); 84 | }, []); 85 | 86 | const breakpointWidth = 1024; 87 | 88 | const styles: CSSProperties = { 89 | display: 'grid', 90 | gridTemplateColumns: 91 | windowWidth <= breakpointWidth ? '1fr 1fr' : '2fr 1fr 1fr 1fr .5fr', 92 | color: 'text.primary', 93 | alignItems: 'center', 94 | fontSize: '1rem', 95 | padding: windowWidth <= breakpointWidth ? '0' : '0.5rem 1.5rem', 96 | gap: windowWidth <= breakpointWidth ? '0.5rem' : '1.5rem', 97 | }; 98 | 99 | return ( 100 |
101 | 102 | 110 | 111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /src/ui/options/components/icon-settings/binding-input-controls.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, TextField } from '@mui/material'; 2 | import { WithBindingProps } from '../../types/binding-control-props'; 3 | 4 | type BindingControlsProps = { 5 | binding: string; 6 | index: number; 7 | placeholder: string; 8 | label: string; 9 | changeBinding: (index: number, value: string) => void; 10 | }; 11 | 12 | export function BindingControls({ 13 | binding, 14 | index, 15 | bindings, 16 | bindingsLabel, 17 | placeholder, 18 | label, 19 | changeBinding, 20 | }: WithBindingProps): JSX.Element { 21 | return bindings ? ( 22 | { 25 | if (value !== null) { 26 | changeBinding(index, value); 27 | } 28 | }} 29 | options={bindings} 30 | sx={{ width: '100%' }} 31 | renderInput={(params) => } 32 | /> 33 | ) : ( 34 | { 40 | changeBinding(index, e.target.value); 41 | }} 42 | placeholder={placeholder} 43 | /> 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/ui/options/components/icon-settings/file-icon-bindings.tsx: -------------------------------------------------------------------------------- 1 | import { Domain } from '@/models'; 2 | import { getListOfFileIcons } from '../../api/icons'; 3 | import { IconBindingControls } from './icon-binding-controls'; 4 | 5 | export function FileIconBindings({ domain }: { domain: Domain }) { 6 | return ( 7 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/options/components/icon-settings/folder-icon-bindings.tsx: -------------------------------------------------------------------------------- 1 | import { Domain } from '@/models'; 2 | import { getListOfFolderIcons } from '../../api/icons'; 3 | import { IconBindingControls } from './icon-binding-controls'; 4 | 5 | export function FolderIconBindings({ domain }: { domain: Domain }) { 6 | return ( 7 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/options/components/icon-settings/icon-binding-controls.tsx: -------------------------------------------------------------------------------- 1 | import { UserConfig, getConfig, setConfig } from '@/lib/user-config'; 2 | import { Domain } from '@/models'; 3 | import { InfoPopover } from '@/ui/shared/info-popover'; 4 | import AddIcon from '@mui/icons-material/Add'; 5 | import DeleteIcon from '@mui/icons-material/Delete'; 6 | import { 7 | Autocomplete, 8 | Box, 9 | Button, 10 | IconButton, 11 | InputAdornment, 12 | TextField, 13 | Tooltip, 14 | } from '@mui/material'; 15 | import { IconAssociations } from 'material-icon-theme'; 16 | import { CSSProperties, useEffect, useState } from 'react'; 17 | import { WithBindingProps } from '../../types/binding-control-props'; 18 | import { BindingControls } from './binding-input-controls'; 19 | import { IconPreview } from './icon-preview'; 20 | 21 | type IconBindingControlProps = { 22 | title: string; 23 | domain: Domain; 24 | iconList: string[]; 25 | configName: keyof Pick< 26 | UserConfig, 27 | 'fileIconBindings' | 'folderIconBindings' | 'languageIconBindings' 28 | >; 29 | placeholder: string; 30 | label: string; 31 | iconInfoText: string; 32 | bindings?: string[]; 33 | bindingsLabel?: string; 34 | }; 35 | 36 | export function IconBindingControls({ 37 | title, 38 | domain, 39 | iconList, 40 | configName, 41 | placeholder, 42 | label, 43 | iconInfoText, 44 | bindings, 45 | bindingsLabel, 46 | }: WithBindingProps) { 47 | type IconBinding = { 48 | binding: string; 49 | iconName: string | null; 50 | }; 51 | 52 | const [iconBindings, setIconBindings] = useState([ 53 | { binding: '', iconName: null }, 54 | ]); 55 | 56 | useEffect(() => { 57 | getConfig(configName, domain.name, false).then((iconBinding) => { 58 | const bindings = Object.entries(iconBinding ?? []).map( 59 | ([binding, iconName]) => ({ 60 | binding, 61 | iconName, 62 | }) 63 | ); 64 | setIconBindings(bindings); 65 | }); 66 | }, []); 67 | 68 | const iconBindingStyle: CSSProperties = { 69 | width: '100%', 70 | display: 'grid', 71 | gridTemplateColumns: '1fr 1fr 2rem', 72 | alignItems: 'center', 73 | gap: '1rem', 74 | marginBottom: '1rem', 75 | }; 76 | 77 | const controlStyling: CSSProperties = { 78 | display: 'flex', 79 | flexDirection: 'column', 80 | gap: '0.5rem', 81 | }; 82 | 83 | const transformIconBindings = (bindings: IconBinding[]): IconAssociations => { 84 | return bindings.reduce((acc, { binding: fileBinding, iconName }) => { 85 | if (iconName === null) { 86 | return acc; 87 | } 88 | return { 89 | ...acc, 90 | [fileBinding]: iconName, 91 | }; 92 | }, {}); 93 | }; 94 | 95 | const updateConfig = (bindings: IconBinding[]) => { 96 | setIconBindings(bindings); 97 | setConfig(configName, transformIconBindings(bindings), domain.name); 98 | }; 99 | 100 | const changeBinding = (index: number, value: string) => { 101 | const newIconBindings = [...iconBindings]; 102 | newIconBindings[index].binding = value; 103 | updateConfig(newIconBindings); 104 | }; 105 | 106 | const onChangeIconName = (index: number, value: string | null) => { 107 | const newIconBindings = [...iconBindings]; 108 | newIconBindings[index].iconName = value; 109 | updateConfig(newIconBindings); 110 | }; 111 | 112 | const addIconBinding = () => { 113 | setIconBindings([...iconBindings, { binding: '', iconName: null }]); 114 | }; 115 | 116 | const removeBinding = (index: number) => { 117 | const newIconBindings = [...iconBindings]; 118 | newIconBindings.splice(index, 1); 119 | setIconBindings(newIconBindings); 120 | updateConfig(newIconBindings); 121 | }; 122 | 123 | return ( 124 |
125 |

{title}

126 |
127 | {iconBindings.map(({ binding, iconName }, index) => ( 128 |
129 | ( 131 | 140 | )} 141 | infoText={iconInfoText} 142 | /> 143 | { 146 | onChangeIconName(index, value); 147 | }} 148 | renderOption={(props, option) => { 149 | const { key, ...optionProps } = props; 150 | return ( 151 | img': { mr: 2, flexShrink: 0 } }} 155 | {...optionProps} 156 | > 157 | 158 | {option} 159 | 160 | ); 161 | }} 162 | options={iconList} 163 | sx={{ width: '100%' }} 164 | renderInput={(params) => ( 165 | 175 | 179 | 180 | ), 181 | }, 182 | }} 183 | label='Icon' 184 | /> 185 | )} 186 | /> 187 |
188 | 189 | removeBinding(index)}> 190 | 191 | 192 | 193 |
194 |
195 | ))} 196 |
197 | 200 |
201 | ); 202 | } 203 | -------------------------------------------------------------------------------- /src/ui/options/components/icon-settings/icon-preview.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@mui/material/styles'; 2 | import { getIconFileName } from '../../api/icons'; 3 | 4 | interface IconPreviewProps { 5 | configName: string; 6 | iconName?: string; 7 | } 8 | 9 | export function IconPreview({ configName, iconName }: IconPreviewProps) { 10 | const theme = useTheme(); 11 | const getIconSrc = (iconName?: string): string | undefined => { 12 | if (configName === 'folderIconBindings') { 13 | return iconName === 'folder' ? 'folder' : `folder-${iconName}`; 14 | } 15 | return iconName; 16 | }; 17 | 18 | if (!iconName) { 19 | return null; 20 | } 21 | 22 | const iconSrc = getIconSrc(iconName)?.toLowerCase(); 23 | if (!iconSrc) { 24 | return null; 25 | } 26 | 27 | return ( 28 | {`${iconName} 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/options/components/icon-settings/icon-settings-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Domain } from '@/models'; 2 | import { 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogTitle, 8 | Typography, 9 | } from '@mui/material'; 10 | import { FileIconBindings } from './file-icon-bindings'; 11 | import { FolderIconBindings } from './folder-icon-bindings'; 12 | import { LanguageIconBindings } from './language-icon-bindings'; 13 | 14 | type IconSettingsDialogProps = { 15 | show: boolean; 16 | domain: Domain; 17 | onClose: () => void; 18 | }; 19 | 20 | export function IconSettingsDialog({ 21 | show, 22 | domain, 23 | onClose, 24 | }: IconSettingsDialogProps) { 25 | return ( 26 | 27 | Configure Icon Bindings 28 | 29 | 30 | Domain: {domain.name} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/options/components/icon-settings/language-icon-bindings.tsx: -------------------------------------------------------------------------------- 1 | import { Domain } from '@/models'; 2 | import { getListOfFileIcons } from '../../api/icons'; 3 | import { getLanguageIds } from '../../api/language-ids'; 4 | import { IconBindingControls } from './icon-binding-controls'; 5 | 6 | export function LanguageIconBindings({ domain }: { domain: Domain }) { 7 | return ( 8 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/ui/options/components/main.tsx: -------------------------------------------------------------------------------- 1 | import { removeCustomProvider } from '@/lib/custom-providers'; 2 | import { Domain } from '@/models'; 3 | import { removeGitProvider } from '@/providers'; 4 | import { InfoPopover } from '@/ui/shared/info-popover'; 5 | import { Logo } from '@/ui/shared/logo'; 6 | import { theme } from '@/ui/shared/theme'; 7 | import { 8 | Alert, 9 | AppBar, 10 | Button, 11 | CssBaseline, 12 | Toolbar, 13 | Typography, 14 | } from '@mui/material'; 15 | import Box from '@mui/material/Box'; 16 | import { ThemeProvider } from '@mui/material/styles'; 17 | import { useEffect, useState } from 'react'; 18 | import { Footer } from '../../shared/footer'; 19 | import { getDomains } from '../api/domains'; 20 | import { ConfirmDialog } from './confirm-dialog'; 21 | import { DomainSettings } from './domain-settings'; 22 | 23 | function Options() { 24 | const [customDomains, setCustomDomains] = useState([]); 25 | const [defaultDomain, setDefaultDomain] = useState(); 26 | const [initialDomains, setInitialDomains] = useState([]); 27 | const [showResetConfirmDialog, setShowResetConfirmDialog] = useState(false); 28 | 29 | useEffect(() => { 30 | updateDomains(); 31 | }, []); 32 | 33 | const resetAll = async () => { 34 | const event = new CustomEvent('RESET_ALL_DOMAINS'); 35 | window.dispatchEvent(event); 36 | }; 37 | 38 | const updateDomains = () => { 39 | return getDomains().then((domains) => { 40 | const customDomainsList = domains.filter((domain) => domain.isCustom); 41 | const initialDomainList = domains.filter( 42 | (domain) => !domain.isCustom && !domain.isDefault 43 | ); 44 | const defaultDomain = domains.find((domain) => domain.isDefault); 45 | 46 | setCustomDomains(customDomainsList); 47 | setInitialDomains(initialDomainList); 48 | setDefaultDomain(defaultDomain); 49 | }); 50 | }; 51 | 52 | const deleteDomain = async (domain: Domain) => { 53 | await removeCustomProvider(domain.name); 54 | await removeGitProvider(domain.name); 55 | await updateDomains(); 56 | }; 57 | 58 | const containerStyling = { 59 | width: '100%', 60 | bgcolor: 'background.default', 61 | borderRadius: 0, 62 | color: 'text.primary', 63 | }; 64 | return ( 65 | <> 66 | 67 | 68 | 69 | 70 | 75 | Material Icons 76 | 77 | 78 | 84 | 85 | 86 | 87 | 88 |
89 |

Default domain

} 92 | /> 93 |
94 | {defaultDomain && } 95 | 96 |

Other domains

97 | {initialDomains.map((domain) => ( 98 | 99 | ))} 100 | 101 | {customDomains.length > 0 &&

Custom domains

} 102 | {customDomains.map((domain) => ( 103 | deleteDomain(domain)} 106 | /> 107 | ))} 108 |
109 | 110 |